Compare commits

..

1 Commits

Author SHA1 Message Date
Pouzor 701c9c5bb9 fix: force frontend builder stage to native platform, fixes QEMU arm64 npm crash 2026-03-28 14:23:21 +01:00
352 changed files with 4375 additions and 49808 deletions
-59
View File
@@ -1,10 +1,6 @@
# Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
# which sits on the same persistent volume as the DB.
# UPLOAD_DIR=./data/uploads
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin
@@ -17,15 +13,6 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
# custom ports. Defaults below are overridable per-scan from the scan dialog.
# SCANNER_HTTP_RANGES: JSON array of port specs, each a single port "N" or an
# inclusive range "N-M" (165535, N <= M). Not CIDRs, not bare ints.
# Example: SCANNER_HTTP_RANGES=["8080","9000-9100"]
SCANNER_HTTP_RANGES=[]
SCANNER_HTTP_PROBE_ENABLED=false
SCANNER_HTTP_VERIFY_TLS=false
# Status checker interval in seconds
STATUS_CHECKER_INTERVAL=60
@@ -35,49 +22,3 @@ STATUS_CHECKER_INTERVAL=60
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
MCP_API_KEY=mcp_sk_changeme
MCP_SERVICE_KEY=svc_changeme
# Live view — read-only public canvas at /view?key=<value>
# Off by default. Set to a random secret to enable.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# LIVEVIEW_KEY=
# Gethomepage widget — read-only stats at /api/v1/stats/summary
# Off by default. Set to a random secret to enable; clients must send
# the same value in the `X-API-Key` header.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# HOMEPAGE_API_KEY=
# Proxmox VE import — pull hosts/VMs/LXC from the Proxmox REST API.
# The token is a credential: kept in memory only, never written to disk by the
# app, never returned by any API. Create it under Datacenter → Permissions →
# API Tokens and grant the read-only PVEAuditor role at path "/".
# Token id format: user@realm!tokenname (e.g. root@pam!homelable).
# Required only for auto-sync; one-off imports can pass the token in the dialog.
# PROXMOX_TOKEN_ID=root@pam!homelable
# PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# PROXMOX_HOST=192.168.1.10
# PROXMOX_PORT=8006
# PROXMOX_VERIFY_TLS=true # set false only for self-signed certs
# Zigbee2MQTT auto-sync — pull the mesh from an MQTT broker on a schedule.
# MQTT credentials are secrets: kept in memory only, never written to disk by
# the app, never returned by any API. Manual imports (the Zigbee dialog) are
# unaffected — this block only powers Settings → Zigbee auto-sync and /sync-now.
# Required only for auto-sync; one-off imports pass their config in the dialog.
# ZIGBEE_MQTT_HOST=192.168.1.20
# ZIGBEE_MQTT_PORT=1883
# ZIGBEE_MQTT_USERNAME=mqttuser # optional
# ZIGBEE_MQTT_PASSWORD=mqttpass # optional
# ZIGBEE_BASE_TOPIC=zigbee2mqtt
# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
# ZWAVE_MQTT_HOST=192.168.1.20
# ZWAVE_MQTT_PORT=1883
# ZWAVE_MQTT_USERNAME=mqttuser # optional
# ZWAVE_MQTT_PASSWORD=mqttpass # optional
# ZWAVE_PREFIX=zwave
# ZWAVE_GATEWAY_NAME=zwavejs2mqtt
# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: pouzor
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-3
View File
@@ -6,9 +6,6 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
smoke-and-integration:
runs-on: ubuntu-latest
+2 -9
View File
@@ -16,21 +16,14 @@ jobs:
matrix:
include:
- image: ghcr.io/pouzor/homelable-backend
context: .
dockerfile: Dockerfile.backend
build_args: ""
- image: ghcr.io/pouzor/homelable-frontend
context: .
dockerfile: Dockerfile.frontend
build_args: ""
- image: ghcr.io/pouzor/homelable-frontend-standalone
context: .
dockerfile: Dockerfile.frontend
build_args: "VITE_STANDALONE=true"
- image: ghcr.io/pouzor/homelable-mcp
context: ./mcp
dockerfile: Dockerfile.mcp
build_args: ""
steps:
- uses: actions/checkout@v4
@@ -62,8 +55,8 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
-3
View File
@@ -6,9 +6,6 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint-scripts:
runs-on: ubuntu-latest
-3
View File
@@ -8,9 +8,6 @@ on:
schedule:
- cron: '0 9 * * 1' # Weekly on Monday
permissions:
contents: read
jobs:
secrets-scan:
runs-on: ubuntu-latest
+1 -8
View File
@@ -1,5 +1,6 @@
# Claude / project meta — never commit
CLAUDE.md
FEATURES.md
.claude/
_project_specs/
@@ -44,15 +45,7 @@ htmlcov/
*.db
*.db-shm
*.db-wal
*.db.back
*.db.back-*
# Docker
.docker/
Ideas.md
# Local dev/test utilities (never commit)
scripts/zwave-mock-gateway.py
# Docs (local only)
docs/database-model.md
-652
View File
@@ -1,652 +0,0 @@
# Changelog
All notable changes to **Homelable** are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [3.1.1] - 2026-07-19
### Features
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
- Getting Started walkthrough tour with first-run canvas detection. (#300)
### Fixes
- Round-trip Show Port Numbers on YAML export/import. (#301)
- Render error toasts on a red surface. (#300)
### Docs
- Rework README header with logo, badges, and nav links. (#300)
## [3.1.0] - 2026-07-18
### Features
- Drag to reorder node properties. (#296)
- Search for nodes and properties. Thanks @Floyddotnet. (#294)
- Widen zone modal into two columns. (#293)
- Autosave canvas after a configurable inactivity delay (opt-in). Thanks @nicolabottini. (#289)
- MCP: `design_id` on approve_device + `delete_design` tool. Thanks @nicolabottini. (#281)
### Fixes
- Scanner: decouple port discovery from version detection. (#295, #277)
- Allow undo after Auto Layout and YAML import. (#290)
- Move edge waypoints when the connected node or its container is dragged. (#288)
- Keep container host height when editing an unrelated field. (#287)
- Keep container children in place when editing a host node. Thanks @nicolabottini. (#286)
- `list_pending_devices` MCP tool leaked approved/hidden inventory rows. Thanks @MikeSviblov. (#273)
### Docs
- Fix MCP client transport to http (was sse). (#275)
## [3.0.0] - 2026-07-10
### Added
- Proxmox VE import: pull hosts, VMs and LXC into inventory, with optional scheduled auto-sync and a manual re-sync button (connection config is now env-only). (#253, #259)
- Scheduled auto-sync for Zigbee & Z-Wave imports. (#270)
- Floor plan map background, LQI-based edge coloring, and Zigbee path highlighting. Thanks @pranjal-joshi. (#207)
- Customizable connection points per node side. (#249)
- Configurable edge line style + width per type and per edge, plus selectable endpoint marker shapes. (#250, #252)
- Create a new canvas by copying an existing one. (#257)
- Prompt on duplicate device instead of silently blocking/merging on approve. (#261)
- Active design synced to URL for refresh/share. (#263)
- MCP can target a specific design/canvas, create canvases, and auto-position nodes with auto-assigned edge handles. Thanks @nicolabottini. (#266)
### Fixed
- Mesh (Zigbee/Z-Wave) import: duplicate-node crash and coordinator now routed to pending. (#247)
- Keep mesh/cluster links so edges resolve onto a second canvas. (#254)
- Preserve edge connection points in YAML export/import. (#255, #208)
- Harden media path handling against path injection. (#256)
- Match scanned devices to canvas nodes by ip-token and MAC. (#262, #258)
- Record ScanRun for scheduled Proxmox auto-sync. (#269)
### Docs
- User-facing FEATURES.md. (#248)
- CHANGELOG with full release history. (#264)
## [2.6.1] - 2026-06-30
### Added
- Z-Wave nodes can be added/edited manually. A Z-Wave group (Controller / Router / End Device) is now in the Add/Edit node type selector, matching Zigbee. Mesh-radio nodes default to no status check.
### Fixed
- Standalone (frontend-only) multi-canvas (designs) support, originally merged in #244 after the 2.6.0 tag with no version bump.
## [2.6.0] - 2026-06-28
Device Pending is gone — replaced by **Device Inventory**: devices stay in an inventory, can be placed in multiple canvases, rescanned and updated. New **Z-Wave** section: scan, import and store Z-Wave devices like Zigbee.
### Added
- SVG export option in the canvas export modal, alongside PNG (#238).
- Deep-scan HTTP probe + Device Inventory: scans probe HTTP services and surface a per-device inventory (#222, closes #195).
- Z-Wave network scan via the MQTT bridge (#224).
- Inventory timestamps on nodes — nodes show when their inventory was last collected (#233).
- New nodes drop at the centre of the visible canvas instead of off-screen (#234).
- Expanded MAC OUI database: router, switch, AP, NAS and camera vendor prefixes (#220).
### Fixed
- Optional white background on export and working downloads in Firefox (#239, closes #165).
- MCP-created nodes/edges attach to the active design instead of floating (#237, closes #225).
- Stopping a scan interrupts the running nmap range immediately (#236, closes #218).
- Auto-layout orders Proxmox children by parent port number (#235).
- Copy-to-clipboard for Markdown works on non-secure HTTP origins (#219).
### Docs
- Documented the `SCANNER_HTTP_RANGES` port-spec format (#232).
## [2.5.1] - 2026-06-18
### Fixed
- Manually resized nodes — including VM/LXC nodes nested inside a Proxmox container — keep their size on reload instead of snapping back to content-fit.
- Node detail panel has a Size section for pixel-exact width/height; fields resync live when dragging a corner handle.
- Adding an LXC/VM under a non-container-mode Proxmox node no longer confines it to a tiny box — it stays a free, draggable node.
### Security
- Resolved high-severity npm audit advisories (`esbuild`, `form-data`, `vite` → 7.3.5).
- Bumped `python-multipart` to 0.0.31 (CVE-2026-53538/53539/53540).
## [2.5.0] - 2026-06-11
### Added
- Scan History modal with run duration, finished timestamp, and kind/status filters (#203).
- Container nesting: drag a node onto a container node to nest it; detach/re-parent via an editable container selector (#202).
- Editable node groups: add/remove members and edit the group description directly (#200).
- Multi-line edge labels (#199).
- Per-service status checks with offline colouring (#198).
### Fixed
- Reduce status flapping, add IPv6 ping, colour manually-added web services (#198).
- Keep non-HTTP services grey instead of red (#198).
- Idempotent WebSocket connection removal — release the slot on any error (#198).
- Persist group description with Ctrl+S, not only on blur (#200).
### Security
- Resolve Dependabot and code-scanning alerts; bump hono to 4.12.21+ (#197).
- Bump zeroconf to 0.149.12 for CVE-2026-48045 (#202).
## [2.4.0] - 2026-06-05
### Added
- Multiple designs (canvases): build and switch between separate diagrams, plus a new electrical-device node type (#177).
- Copy & paste nodes between designs (#189).
- Switch port numbers (up to 64 ports) plus a new fibre-optic link style (#172).
- Read-only Live View link in the header for sharing or wall displays (#185).
- Scans keep discovered MAC addresses when a device is approved (#178).
- Richer AI/MCP control: assistants can set the full set of node fields on create/update (#180).
### Changed
- Settings moved into a dedicated window for a cleaner sidebar (#188).
- "Hide IP" option now lives in Settings and is remembered between sessions (#189).
- Smoother link animations for flowing/snake styles (#187).
### Fixed
- "Show Port Numbers" now persists after reload (#191).
- The Save button works again (previously only Ctrl/Cmd+S) (#190).
- Zigbee re-import correctly brings back already-approved devices (#176).
### Security
- Dependency updates: zeroconf, qs, brace-expansion (#175, #182).
## [2.3.0] - 2026-05-31
### Added
- Full node schema over MCP: `create_node` / `update_node` expose the complete backend schema (`os`, `notes`, `mac`, `services`, `cpu_count`, `cpu_model`, `ram_gb`, `disk_gb`, `properties`, …); `get_canvas` round-trips the same fields (#174).
- MAC carried onto approved nodes from a scan (#168).
- Richer switch & edge modeling: switch port cap raised to 64, port numbers shown, new fibre edge type.
### Fixed
- Canvas includes the MAC property on the approved node so it shows immediately after approval (#168).
- Zigbee: revive orphaned approved devices on re-import instead of dropping them (#167).
### Security
- Bump `zeroconf` 0.131.0 → 0.149.7 for CVE fixes.
## [2.2.0] - 2026-05-29
### Added
- Laptop & mobile node types with theme-coherent styling (#166).
- Collapsible / expandable zones — `collapsed` is now a first-class `NodeData` field with edge rewiring and read-only liveview support (#158).
- LXC / bare-metal MCP install script `lxc-mcp-install.sh` with env-var overrides and repo-clone fallback; MCP image published to GHCR (#163).
## [2.1.1] - 2026-05-16
### Fixed
- LiveView: nest Docker container children under their host node; apply saved theme & custom style on load.
## [2.1.0] - 2026-05-16
### Added
- Draggable edge endpoints — reconnect either end of an edge to another node/handle; Proxmox containers gain snap points (#150).
- Docker containers can live inside VMs, Proxmox hosts or LXCs; parent auto-cleaned on type change (#153, #154).
- Group node side handles (top/right/bottom/left) for proper edge attachment (#152).
- Multiple IPs per node — paste several IPs separated by comma/space/newline, each clickable individually (#136).
- Zigbee device properties (IEEE, Vendor, Model, LQI) auto-populated on approve / re-import (#148).
- Homepage widget stats endpoint: new `/stats` API for gethomepage integration (closes #131, #149).
- Zone modal polish: centered opacity thumb, fixed font casing, full keyboard + ARIA support (#106).
### Security
- Dropped `passlib` in favour of direct `bcrypt`; status-check targets reject CLI-flag injection.
## [2.0.3] - 2026-05-13
### Changed
- Remove check method for Zigbee nodes.
## [2.0.2] - 2026-05-11
### Added
- Activated dashboardicons icons — brand-new icons selectable for nodes.
### Fixed
- Various modal fixes.
## [2.0.1] - 2026-05-11
### Added
- New Zigbee nodes available in the new/edit node modal.
## [2.0.0] - 2026-05-11
A major milestone: Zigbee2MQTT integration, a new pending-devices modal, text nodes and alignment guides.
### Added
- **Zigbee2MQTT integration** (thanks @pranjal-joshi): import a full Zigbee network map from Z2M as a background scan run; three new node types (Coordinator, Router, End Device); MQTT TLS with optional cert-verify skip; imported devices flow through the pending section with edge persistence.
- **New pending devices modal**: full-screen grid with cards, filters (IP / Zigbee / all), search, multi-select and bulk restore.
- **Text node type** for free-form annotations (#138).
- **Alignment guides + snap** while dragging nodes, built on `OnNodeDrag` (#139).
- **Per-node services toggle** to show/hide the services list on the node (thanks @findthelorax) (#107).
- Zigbee node types exposed in the Custom Style editor.
### Fixed
- Status checker: ms timeout for ping on macOS.
- Scan: default `check_method='ping'` for approved devices.
- Detail panel: handle non-Z timezone offsets in Last Seen.
- Groups: preserve children + size on edit; fix status WebSocket proxy.
- Pending: drop dangling `onNodeApproved` call; null-safe pending IP in SearchModal.
- Text node: persist text in `label` across reloads.
### Security
- Bump `fast-uri` (GHSA-q3j6-qgpj-74h6, GHSA-v39h-62p7-jpjc), `axios` ^1.15.2, `python-multipart`.
- Sanitize MQTT error messages to prevent credential leakage.
## [1.13.0] - 2026-05-03
### Added
- New **Firewall** node type under Add Node → Hardware, with distinct flame icon and red accent.
- Up to **48 bottom connection points** per node (slider, previously capped at 4); the node card grows wider as handles are added.
### Fixed
- Sidebar no longer freezes on Scan History after starting a scan.
- Delete confirmation respects Cancel — clicking Cancel keeps the modal open.
## [1.12.0] - 2026-04-24
### Added
- **Custom Style Editor**: per-node-type border/background/icon colour + opacity, per-edge colour/opacity/path/animation, default size per type, "Apply to existing nodes" and "Apply All to Canvas"; saved with the canvas.
### Changed
- Accessibility overhaul: consistent hover/focus borders, pointer cursors, keyboard navigation and Enter-to-apply across all modals (#108).
- IP address hint moved below the field; hover border colour toned down.
### Fixed
- Edge labels and the `+` waypoint handle stay on the routed path when waypoints are used (#94).
- Edge type select displays its full label (e.g. "IoT / Zigbee") instead of the raw value.
- Status dot overlapping IP address in node cards.
- Canvas style modal layout on smaller screens.
- MCP session manager routing.
## [1.11.0] - 2026-04-23
### Added
- **Docker Host** node type with container mode support (visual group for containers).
- **Docker Container** node type, nestable inside a Docker Host.
- Container mode now works for `docker_host`, `vm` and `lxc` (was Proxmox-only).
### Changed
- Service badges prioritise the service name; path truncates gracefully with a hover tooltip.
- Node resizer handle hit area enlarged (8px → 16px).
### Fixed
- Container mode not persisting after save/reload for non-Proxmox types.
- Container mode toggle having no visual effect on `docker_host` nodes.
- Status dot overlapping node content; consistent top-right placement.
- Missing icon/label gap in ProxmoxGroupNode container header.
- IP shown unmasked in ProxmoxGroupNode when hide-IP is enabled.
## [1.10.2] - 2026-04-21
### Added
- **Logout button** at the bottom of the sidebar (normal mode only).
### Fixed
- Pending Devices checkbox click no longer opens the approval modal.
- NaN sent to the API when the status-check interval input is cleared.
- Potential open-redirect in the update badge (release URL scheme now validated).
## [1.10.1] - 2026-04-20
### Added
- PNG export quality selector: Standard (1×), High (2×, default), Ultra (4×) (#89).
- Zone colour opacity sliders for text/border/background (#72).
- Optional service paths (e.g. `/admin`) appended to the clickable URL; port optional when a path is set (thanks @findthelorax) (#86).
### Fixed
- Bulk-approved nodes appear on canvas immediately (node IDs were null before DB flush).
- Proxmox container mode: visible properties now shown; custom icon now applied.
- `approve_device` returns 404 on missing device, 409 on double-approve (was 200).
- Node form no longer retains previous-session values when reopened in Add mode (#87).
## [1.10.0] - 2026-04-19
### Added
- Bulk approve/hide pending devices (#70).
- IPv6 support & multiple comma-separated IPs per node (#60).
- Clickable IP addresses in the detail panel (#78).
- Connection handles on zone/group rect nodes (#58).
- Automatic DB backup before schema migrations.
- Drag group from its title (#76); double-click a node to open its edit modal (#65).
### Fixed
- Node width no longer expands when long content overflows after a user resize.
- Proxmox nodes with `container_mode=false` restore their saved width on reload.
- Added curl to the backend image for the default healthcheck.
## [1.9.0] - 2026-04-09
### Added
- **Node properties system**: dynamic key/value/icon/visible properties replacing static hardware fields; visible ones shown on the node card; 20 Lucide icons; existing hardware data auto-migrated.
- **Edge waypoints**: click `+` to add, drag to move, double-click to remove; Bezier and Smooth step supported; persisted to backend.
- **Basic edge animation** mode (native moving-dash), alongside Snake and Flow.
- App version shown in sidebar with a GitHub release update check.
### Fixed
- Node height no longer overflows when properties are added to a resized node.
- Dot grid alignment with the snap grid.
- Node selection layout shift.
## [1.8.3] - 2026-04-06
### Added
- Finer canvas grid: snap reduced from 16px to 8px.
### Changed
- Updated frontend npm dependencies; upgraded lucide-react; removed unused Proxmox/LXC install scripts.
## [1.8.2] - 2026-04-05
### Fixed
- Scan no longer starts before confirmation — "Scan Network" opens the config modal first.
- Windows ping compatibility (`-n 1 -w 1000` instead of Linux/macOS flags).
## [1.8.1] - 2026-04-05
### Added
- Search now includes pending devices in both the canvas search bar (Ctrl+F) and command palette (Ctrl+K).
### Fixed
- Timestamps (scan history, discovery time, Last Seen) now show local time instead of UTC.
## [1.8.0] - 2026-04-04
### Added
- Configurable 14 bottom connection points per node.
- Fit view on load.
- Inline Type and Icon pickers in the node modal.
### Changed
- **Scanner rewrite**: Phase 1 concurrent asyncio ping sweep (50 parallel pings) supplemented by `/proc/net/arp`; Phase 2 explicit `-sS`/`-sT` scan type with 60s host-timeout; resilient gather so one failing host no longer aborts the batch.
### Fixed
- Root logger StreamHandler so `app.*` logs are visible in Docker.
- CIDR validation on frontend and backend to prevent nmap argument injection.
- Pre-fetch canvas/hidden IPs before the scan loop (no N+1 queries); ARP table read off the event loop.
- Hide/ignore on a missing device returns 404 instead of 500.
## [1.7.1] - 2026-04-02
### Added
- Concurrent status checks via `asyncio.gather`, ending "maximum instances reached" spam.
- Improved IoT detection: two-phase nmap scan finds Shelly, Sonoff, Tapo devices with no open TCP ports.
- mDNS/Bonjour discovery (`_shelly._tcp`, `_esphomelib._tcp`, `_hap._tcp`, `_mqtt._tcp`, …) in parallel with nmap.
- 20+ IoT vendor MAC OUIs and CoAP ports added; IoT ranked above generic server.
### Fixed
- LXC/VM container mode: attaching to a Proxmox no longer creates a spurious edge; nesting is instant.
- Scheduler reliability: DetachedInstanceError, duplicate timestamps, shutdown handling, interval validation.
### Dependencies
- Added `zeroconf==0.131.0` for mDNS discovery.
## [1.7.0] - 2026-04-01
### Added
- **Lasso / box selection** — draw a rectangle to select multiple nodes; hold Space to pan; lasso/pan toggle in the controls.
- **Named groups** — group 2+ nodes into a resizable, renamable container with hide-border option and per-member navigation; persisted across save/reload.
- **Canvas search (Ctrl+F)** — filter nodes by label, IP, hostname, service; live match count; click to fly the camera.
### Fixed
- LXC install script: `apt-get update` runs verbosely with `--fix-missing` to handle stale Debian mirrors (fixes #36).
- Group parent/child relationships restore after save + reload.
- Removed React Flow's default grey background from container node types.
## [1.6.0] - 2026-03-30
### Added
- Scan deduplication — pending devices already on canvas or hidden are skipped on rescan; stale entries purged at scan start.
- Dedicated Settings panel (status-check interval moved out of the Scan Config modal).
- Live interval update without a server restart.
- Separate `/api/v1/settings` endpoint for the check interval.
### Fixed
- DEL key deletes selected nodes (previously Backspace only).
- Node deletion via shortcut/detail panel is undoable with Ctrl+Z.
- APScheduler guards against double-start and unguarded reschedule calls.
## [1.5.0] - 2026-03-29
### Added
- Inline service editing in the detail panel (pencil icon).
- Edge animation modes: None, Snake, Flow — fully persisted.
- Zone improvements: rename Rectangle → Zone, border-width selector, label position, text-size options.
### Fixed
- Edge animation not saved on new connections.
- Backend 422 when saving canvas with string animation values.
- Snake and Flow animations rendering identically in production.
- Login returned 500 instead of 401 when a bcrypt hash had `$` stripped by the shell (#21).
- Hardcoded CORS in docker-compose; clearer login error messages.
## [1.4.0] - 2026-03-28
### Added
- **Live View** — share a read-only view of the canvas on your network, no login required.
- Resizable nodes with persisted width/height.
### Fixed
- QEMU arm64 crash during Docker image build (`npm ci` illegal instruction on ARM64).
- nginx `reload-or-start` fallback broken on fresh LXC installs.
### CI
- ShellCheck + hadolint linting; Docker smoke tests and full-stack integration tests.
## [1.3.3] - 2026-03-27
### Fixed
- Scan failures on fresh Docker installs — `service_signatures.json` moved into the app package so the volume mount no longer overwrites it.
- Missing `ping` on Docker (`iputils-ping` added).
- Thread-safe signature-file loading; clear error if the file is missing.
- CORS restricted to the HTTP methods/headers the frontend uses.
## [1.3.2] - 2026-03-27
### Fixed
- Ping check method on fresh Docker installs — `iputils-ping` added to the backend image (`python:3.13-slim` ships without `ping`).
## [1.3.1] - 2026-03-27
### Added
- **YAML import/export** of the full canvas topology (nodes, edges, hardware specs, connections); import merges without overwriting; Dagre auto-layout on import; toolbar Import/Export buttons.
### Fixed
- ESLint 10 incompatibility from `npm audit fix` — pinned back to ESLint 9.x.
### Docs
- Split installation into `INSTALLATION.md`; documented the network scanner and dev mode.
## [1.3.0] - 2026-03-21
### Added
- Hardware specs per node (CPU model, cores, RAM, Disk) with "Show on node" and GB → TB formatting.
- `Docker Host` node type (Anchor icon), themed across all 5 themes.
- Group rectangle border style: Solid, Dashed, Dotted, Double, None.
- Categorized node-type selector (Hardware / Virtualization / IoT / Generic).
## [1.2.2] - 2026-03-17
### Added
- `scripts/update.sh` for fast in-place LXC updates (never touches `.env` or DB).
### Fixed
- `crypto.randomUUID` crash on HTTP/LXC installs — polyfill fallback.
- WebSocket failure on LXC (hardcoded port 8000 bypassing Nginx); added `/api/v1/status/ws/` upgrade block.
- Production build crash on LXC (test files in `tsconfig.app.json`).
### Security
- JWT no longer exposed in the WebSocket URL — sent as the first message after connect.
## [1.2.1] - 2026-03-16
### Added
- **MCP server** with HTTP/SSE transport for AI integration (Claude Code compatible); `parent_id` exposed in `update_node`; service-key auth for MCP → backend.
### Fixed
- "Add node" appeared broken with an empty Label — native validation doesn't render in Radix Dialog portals; now shows an inline error.
- SSE streaming crash; reduced `get_canvas` token usage.
## [1.2.0] - 2026-03-13
### Added
- Edge flow animation (per-edge toggle); Proxmox cluster edges animate bidirectionally.
- Keyboard shortcuts: Undo/Redo, Ctrl+K search, copy/paste, Ctrl+S save, `?` reference.
- Canvas history (50-entry undo/redo stack) with toolbar buttons.
- Node search spotlight (Ctrl+K) with fuzzy match and fly-to.
- Copy/paste nodes (Ctrl+C / Ctrl+V) with 50px offset and fresh IDs.
- Markdown table export of the node inventory.
- Clickable hostname in the detail panel.
- Shortcuts reference modal.
## [1.1.1] - 2026-03-11
### Added
- New logo and favicon; page title updated to Homelable.
- **Hide IPs** toggle in the sidebar — masks the last two octets.
### Fixed
- Auto-layout: peer nodes placed on the same row (no staircase); correct left-to-right ordering; child nodes always below their parent.
## [1.1.0] - 2026-03-10
### Added
- **Group rectangles** — decorative resizable zones with configurable label/font/position/colours and z-order; saved with the canvas.
- Add/remove services manually in the detail panel.
- All TCP services now clickable (HTTPS auto-detected for 443/8443; non-web ports excluded).
## [1.0.0] - 2026-03-09
First stable public release of **Homelable**, a self-hosted homelab visualization tool.
### Added
- **Canvas**: interactive React Flow diagram; 11 node types; 5 edge types; Proxmox nested nodes; Dagre auto-layout; zoom/pan; snap-to-grid; PNG export.
- **Network discovery**: nmap scanner over CIDR ranges; pending-device queue (approve/hide/ignore); service fingerprinting; MAC OUI detection for QEMU/Proxmox/VMware.
- **Status monitoring**: per-node ping/http/https/tcp/ssh/prometheus/health checks; live WebSocket updates; scheduled background checks.
- **Auth & persistence**: single-user JWT auth (bcrypt in `.env`); SQLite canvas state with an explicit Save button.
- **Standalone mode**: backend-free diagram editor with localStorage persistence.
- **Install options**: Docker Compose, Proxmox LXC, manual Debian/Ubuntu script.
[2.6.1]: https://github.com/Pouzor/homelable/compare/v2.6.0...v2.6.1
[2.6.0]: https://github.com/Pouzor/homelable/compare/v2.5.1...v2.6.0
[2.5.1]: https://github.com/Pouzor/homelable/compare/v2.5.0...v2.5.1
[2.5.0]: https://github.com/Pouzor/homelable/compare/v2.4.0...v2.5.0
[2.4.0]: https://github.com/Pouzor/homelable/compare/v2.3.0...v2.4.0
[2.3.0]: https://github.com/Pouzor/homelable/compare/v2.2.0...v2.3.0
[2.2.0]: https://github.com/Pouzor/homelable/compare/v2.1.1...v2.2.0
[2.1.1]: https://github.com/Pouzor/homelable/compare/v2.1.0...v2.1.1
[2.1.0]: https://github.com/Pouzor/homelable/compare/v2.0.3...v2.1.0
[2.0.3]: https://github.com/Pouzor/homelable/compare/v2.0.2...v2.0.3
[2.0.2]: https://github.com/Pouzor/homelable/compare/v2.0.1...v2.0.2
[2.0.1]: https://github.com/Pouzor/homelable/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/Pouzor/homelable/compare/v1.13.0...v2.0.0
[1.13.0]: https://github.com/Pouzor/homelable/compare/v1.12.0...v1.13.0
[1.12.0]: https://github.com/Pouzor/homelable/compare/v1.11.0...v1.12.0
[1.11.0]: https://github.com/Pouzor/homelable/compare/v1.10.2...v1.11.0
[1.10.2]: https://github.com/Pouzor/homelable/compare/v1.10.1...v1.10.2
[1.10.1]: https://github.com/Pouzor/homelable/compare/v1.10.0...v1.10.1
[1.10.0]: https://github.com/Pouzor/homelable/compare/v1.9.0...v1.10.0
[1.9.0]: https://github.com/Pouzor/homelable/compare/v1.8.3...v1.9.0
[1.8.3]: https://github.com/Pouzor/homelable/compare/v1.8.2...v1.8.3
[1.8.2]: https://github.com/Pouzor/homelable/compare/v1.8.1...v1.8.2
[1.8.1]: https://github.com/Pouzor/homelable/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/Pouzor/homelable/compare/v1.7.1...v1.8.0
[1.7.1]: https://github.com/Pouzor/homelable/compare/v1.7.0...v1.7.1
[1.7.0]: https://github.com/Pouzor/homelable/compare/v1.6.0...v1.7.0
[1.6.0]: https://github.com/Pouzor/homelable/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/Pouzor/homelable/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/Pouzor/homelable/compare/v1.3.3...v1.4.0
[1.3.3]: https://github.com/Pouzor/homelable/compare/v1.3.2...v1.3.3
[1.3.2]: https://github.com/Pouzor/homelable/compare/v1.3.1...v1.3.2
[1.3.1]: https://github.com/Pouzor/homelable/compare/v1.3.0...v1.3.1
[1.3.0]: https://github.com/Pouzor/homelable/compare/v1.2.2...v1.3.0
[1.2.2]: https://github.com/Pouzor/homelable/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/Pouzor/homelable/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/Pouzor/homelable/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/Pouzor/homelable/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/Pouzor/homelable/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/Pouzor/homelable/releases/tag/v1.0.0
-276
View File
@@ -1,276 +0,0 @@
# Contributing to Homelable
Thanks for taking the time to contribute! This document covers everything you need to get started.
---
## Table of Contents
- [Ways to Contribute](#ways-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Testing](#testing)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Commit Message Format](#commit-message-format)
---
## Ways to Contribute
- Report bugs or unexpected behavior
- Suggest new features or improvements
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
- Improve documentation
- Add service signatures to `service_signatures.json`
---
## Reporting Bugs
Before opening an issue, search existing ones to avoid duplicates.
When filing a bug, include:
- **Homelable version** (visible in the sidebar bottom-left)
- **Deployment method** (Docker Compose, Proxmox LXC, source)
- **Steps to reproduce**
- **Expected vs actual behavior**
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
- **Browser console errors** if it's a UI issue
---
## Suggesting Features
Open an issue with the `enhancement` label. Describe:
- The problem you're trying to solve
- Your proposed solution
- Any alternatives you considered
For large changes, discuss first before writing code — it avoids wasted effort.
---
## Development Setup
### Prerequisites
- **Node.js 20+** and **npm**
- **Python 3.113.13** (3.14 not yet supported by all dependencies)
- **nmap** installed on your system (required for scanner)
- **Docker + Docker Compose** (optional, for full-stack testing)
### 1. Clone the repo
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
```
### 2. Backend
```bash
cd backend
python3.13 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Copy and configure environment
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
# Start the backend (auto-reloads on change)
uvicorn app.main:app --reload --port 8000
```
API docs available at `http://localhost:8000/docs`.
### 3. Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
Vite proxies `/api` to `localhost:8000` — the backend must be running.
### 4. Verify tooling
```bash
./scripts/verify-tooling.sh
```
---
## Project Structure
```
homelable/
├── frontend/src/
│ ├── components/
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
│ │ ├── panels/ # Sidebar, detail panel, toolbar
│ │ ├── modals/ # Add/edit node, scan config, pending devices
│ │ └── ui/ # Shadcn/ui base components
│ ├── stores/ # Zustand state (canvas, auth, scan)
│ ├── hooks/ # Custom React hooks
│ ├── types/ # TypeScript interfaces & enums
│ ├── api/ # Axios client & typed endpoints
│ └── utils/ # Layout, export, color helpers
├── backend/app/
│ ├── api/routes/ # FastAPI route handlers
│ ├── services/ # Scanner, status checker, canvas service
│ ├── db/ # SQLAlchemy models, Alembic migrations
│ ├── schemas/ # Pydantic request/response schemas
│ └── core/ # Config, JWT, scheduler
├── docker/ # Nginx configs
├── scripts/ # LXC bootstrap, dev helpers
└── mcp/ # MCP server (AI integration)
```
---
## Coding Standards
### General
- No untested code merged — every feature or fix must include tests
- Keep changes focused — one concern per PR
### Frontend (TypeScript + React)
- Strict TypeScript — no `any`, no type assertions unless truly necessary
- React Flow node domain fields go in `node.data`, never on the node root
- State management via Zustand stores — no prop drilling beyond 2 levels
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
- Run before committing:
```bash
cd frontend
npm run lint
npm run typecheck
npm test
```
### Backend (Python + FastAPI)
- Python 3.11+ syntax
- Pydantic v2 schemas for all request/response types
- SQLAlchemy async sessions — never block the event loop
- Scanner logic runs in a background thread — never in an async route directly
- All schema changes via Alembic migrations — never modify tables directly
- Run before committing:
```bash
cd backend
source .venv/bin/activate
ruff check .
pytest
```
### Design System
| Token | Value |
|---|---|
| Background | `#0d1117` |
| Surface | `#161b22` |
| Card | `#21262d` |
| Accent cyan | `#00d4ff` |
| Online | `#39d353` |
| Offline | `#f85149` |
| Pending | `#e3b341` |
| Font (UI) | Inter |
| Font (IPs/ports) | JetBrains Mono |
---
## Testing
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
### Frontend
```bash
cd frontend
npm test # run all tests
npm run test:coverage # with coverage report
```
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
### Backend
```bash
cd backend
source .venv/bin/activate
pytest # run all tests
pytest -v tests/test_nodes.py # single file
```
Test files live in `backend/tests/test_*.py`.
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
---
## Submitting a Pull Request
1. **Fork** the repo and create a branch from `main`:
```bash
git checkout -b feat/my-feature
```
2. **Make your changes** — include tests.
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
4. **Open a PR** against `main`:
- Use a clear title (see commit format below)
- Describe what changed and why
- Reference any related issues (`Closes #123`)
- Include screenshots for UI changes
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
---
## Commit Message Format
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>: <short description>
[optional body]
```
| Type | When to use |
|---|---|
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `refactor` | Code change with no behavior change |
| `test` | Adding or fixing tests |
| `chore` | Build, deps, tooling |
**Examples:**
```
feat: add logout button to sidebar
fix: stop click propagation on pending device checkbox
docs: add CONTRIBUTING.md
```
---
## Questions?
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
+2 -3
View File
@@ -2,14 +2,13 @@ FROM python:3.13-slim
WORKDIR /app
# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
# Install nmap for network scanning + iputils-ping for ping-based status checks
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
COPY VERSION /app/VERSION
# Create data directory (volume mount point)
RUN mkdir -p /app/data
+1 -3
View File
@@ -1,8 +1,7 @@
# Stage 1: build
# Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent.
# node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine.
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE
@@ -12,7 +11,6 @@ COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
COPY VERSION ../VERSION
RUN npm run build
# Stage 2: serve
-255
View File
@@ -1,255 +0,0 @@
# Homelable Features
Here's what Homelable can do. One line on what each feature is, then how to switch it on and use it.
> **Two modes.** Same UI, two ways to run it:
> - **Full mode**, with the backend (Docker/LXC). Everything works.
> - **Standalone mode** (`VITE_STANDALONE=true`), no backend, canvas lives in your browser's `localStorage`. Great for just drawing; anything that needs a server (scanning, imports, device inventory, floor-plan upload, live view) is hidden.
>
> Features marked 🔒 need Full mode.
---
## Table of Contents
1. [Zones](#1-zones)
2. [Groups & Nesting](#2-groups--nesting)
3. [Text Annotations](#3-text-annotations)
4. [Multiple Canvases](#4-multiple-canvases)
5. [Customize Style](#5-customize-style)
6. [Floor Plan](#6-floor-plan-)
7. [Network Scanner (IP import)](#7-network-scanner-ip-import-)
8. [Zigbee Import](#8-zigbee-import-)
9. [Z-Wave Import](#9-z-wave-import-)
10. [Proxmox VE Import](#10-proxmox-ve-import-)
11. [Device Inventory](#11-device-inventory-)
12. [Live Status Monitoring](#12-live-status-monitoring-)
13. [Export (PNG / SVG / YAML / Markdown)](#13-export)
14. [Live View (read-only public canvas)](#14-live-view-)
15. [Gethomepage Widget](#15-gethomepage-widget-)
16. [MCP Server (AI integration)](#16-mcp-server-)
17. [Settings & Shortcuts](#17-settings--shortcuts)
---
## 1. Zones
**What:** Labeled boxes to group devices by area: "Living room", "Rack 1", "DMZ", whatever makes sense to you.
**Use:**
- Sidebar → **Add Zone**. Give it a title and a color, then drag it around and resize it.
- Drop nodes onto a zone and Homelable asks if you want to add them to it.
- Zones sit behind your nodes and move on their own. They're just there to keep things tidy.
---
## 2. Groups & Nesting
**What:** Some devices hold others, like a **Proxmox** host with its **VMs** and **LXCs** inside. Those show up as an expandable container.
**Use:**
- Drag a `vm` or `lxc` onto a `proxmox` node, confirm **Add to container**, and it becomes a child.
- Click the container header to fold it open or shut; it resizes itself around what's inside.
- The Zigbee and Z-Wave imports build the same kind of hierarchy for you (coordinator → routers → end devices).
---
## 3. Text Annotations
**What:** Loose text labels for notes, section titles, or anything you want to call out on the canvas.
**Use:** Sidebar → **Add Text**, type, drop it where you want, style it.
---
## 4. Multiple Canvases
**What:** More than one diagram in a single install, say "Network", "Home automation", "Rack layout", each with its own nodes, links, floor plan, and style.
**Use:**
- The **canvas switcher** is at the top of the sidebar. Click to jump between canvases, or **New Canvas** to start a fresh one.
- Hover a canvas to **Edit** it (name, icon, floor plan) or **Delete** it. You can't delete the last one.
- Each canvas saves on its own, so hit **Save Canvas** after you change something.
---
## 5. Customize Style
**What:** Repaint the whole thing with a preset theme, or roll your own node and edge colors.
**Use:**
- Toolbar → **Style**. Pick a preset: **Default**, **Dark**, **Light**, **Neon**, or **Matrix**.
- Or pick **Custom** and hit its **Edit** button to set border/background colors per node type and link colors per edge type.
- Theme and custom colors are saved **per canvas** on your next **Save Canvas**.
---
## 6. Floor Plan 🔒
**What:** Put a background image (a house plan, an office layout, a rack diagram) behind a canvas and lay your devices out on top of it.
**Use:**
- Open the **canvas switcher****Edit** the active canvas (or double-click the floor plan already on the canvas).
- In the **Floor Plan** section, upload an image and set its size and lock state.
- The image lives on the backend and is loaded by URL, never baked into the canvas, so your canvases stay light. *(See ADR-001; floor plans are Full mode only.)*
---
## 7. Network Scanner (IP import) 🔒
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
**Use:**
1. Sidebar → **Scan Network**. Scan History opens and keeps refreshing until it's done.
2. Set the CIDR ranges you want in `SCANNER_RANGES` (`.env`), or override them per scan in the dialog.
3. Whatever it finds shows up in the **Device Inventory** (below) to approve, hide, or ignore.
**Deep scan (custom ports):** to catch services on odd ports, add them in `.env`:
```env
SCANNER_HTTP_RANGES=["8080","9000-9100"]
SCANNER_HTTP_PROBE_ENABLED=true
SCANNER_HTTP_VERIFY_TLS=false
```
**Root note:** SYN scans and OS detection need root. If a scan trips on permissions, run `scripts/run_scan.py` with `sudo`, or on Linux give nmap the `NET_RAW` capability. Full details in the [README](./README.md#network-scanner).
---
## 8. Zigbee Import 🔒
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
**Use:**
1. Sidebar → **Zigbee Import**.
2. Enter broker host/port (default `1883`), any credentials, and the base topic (default `zigbee2mqtt`).
3. **Test Connection****Fetch Devices** → pick from the grouped list → **Add N to Canvas**.
Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. The hierarchy (coordinator → routers → end devices) and **LQI** are filled in automatically. More: [docs/zigbee-import.md](./docs/zigbee-import.md).
---
## 9. Z-Wave Import 🔒
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
**Use:**
1. Sidebar → **Z-Wave Import**.
2. Enter broker host/port, any credentials, the MQTT prefix (default `zwave`), and the gateway name (default `zwavejs2mqtt`).
3. **Test Connection** → send them to **Pending** or straight to the **Canvas** → import → pick devices → **Add N to Canvas**.
Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy comes from each node's neighbor list (Z-Wave has no LQI). More: [docs/zwave-import.md](./docs/zwave-import.md).
---
## 10. Proxmox VE Import 🔒
**What:** Pull your **Proxmox VE** inventory (hosts, VMs, LXC) in over the Proxmox REST API — typed, named nodes with run state and hardware specs. Optional scheduled **auto-sync**; guest IPs already found by a scan are merged, not duplicated.
**Use:**
1. Create a read-only API token in Proxmox (Datacenter → Permissions → API Tokens, role `PVEAuditor`).
2. Sidebar → **Proxmox Import**.
3. Enter host, port (default `8006`), and the token (`user@realm!tokenid` + secret) — or leave blank to use the server token.
4. **Test Connection** → send to **Pending** or the **Canvas** → import → pick devices → **Add N to Canvas**.
Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge. The token is env-only, never stored on disk, never returned by the API. Enable auto-sync from **Settings** once `PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET` are set. More: [docs/proxmox-import.md](./docs/proxmox-import.md).
---
## 11. Device Inventory 🔒
**What:** The holding pen for everything found by a scan or import that isn't on the canvas yet, plus a separate **Hidden Devices** list.
**Use:**
- Sidebar → **Device Inventory**. Each entry shows IP, MAC, hostname, and any OS and services detected.
- Per device: **Approve** to drop a typed node on the canvas, **Hide** to stash it (you can get it back), or **Ignore** to dismiss it.
- **Hidden Devices** is the sidebar entry where you review and restore anything you've hidden.
---
## 12. Live Status Monitoring 🔒
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
**Use:**
- Pick a **check method** per node when you add or edit it:
| Method | Checks |
|--------|--------|
| `ping` | ICMP reachability |
| `http` | GET, OK if status < 500 |
| `https` | GET with TLS verify |
| `tcp` | TCP connect to `host:port` |
| `ssh` | TCP connect to port 22 |
| `prometheus` | GET `/metrics` |
| `health` | GET `/health` |
- Checks run on a timer (`STATUS_CHECKER_INTERVAL`, 60s by default) and stream to the UI over WebSocket, no refresh. The sidebar footer keeps a running online/offline tally.
---
## 13. Export
**What:** Get your canvas out as a picture or as structured data.
**Use (toolbar):**
- **PNG**, a snapshot of the canvas, quality of your choice. Works in standalone too.
- **SVG**, vector export, keeps fonts, icons, and colors crisp. Same dialog as PNG.
- **Export (YAML)**, the whole canvas (nodes + links) as YAML you can re-import.
- **Markdown**, copies your device inventory as a Markdown table, handy for docs or a README.
---
## 14. Live View 🔒
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
**Use:**
1. Add `LIVEVIEW_KEY=your-secret-key` to `.env`, then `docker compose restart backend`.
2. Open `http://<your-homelab-ip>/view?key=your-secret-key`.
Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab.
---
## 15. Gethomepage Widget 🔒
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
**Use:**
1. Add `HOMEPAGE_API_KEY=your-secret-key` to `.env`, restart the backend.
2. `GET /api/v1/stats/summary` with header `X-API-Key: your-secret-key` returns node counts, online/offline, pending, zigbee, and last scan time.
Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-stats).
---
## 16. MCP Server 🔒
**What:** A [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude Code, Claude Desktop, Open WebUI…) can read and change your topology. Optional, runs as its own service.
**Use:**
1. Add the keys to `.env`:
```env
MCP_API_KEY=mcp_sk_changeme # AI client -> MCP server
MCP_SERVICE_KEY=svc_changeme # MCP server -> backend (internal only)
# generate: python3 -c "import secrets; print(secrets.token_hex(32))"
```
2. `docker compose up -d mcp` (listens on `:8001`). No Docker? `sudo bash scripts/lxc-mcp-install.sh`.
3. Point your client at `http://<your-homelab-ip>:8001/mcp` with header `X-API-Key: <your key>`.
The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and edges, kick off scans, and approve or hide devices. Keep port 8001 firewalled to your LAN. Full setup in the [README](./README.md#mcp-server-ai-integration-optional).
---
## 17. Settings & Shortcuts
**What:** App config and keyboard shortcuts.
**Use:**
- Sidebar → **Settings** for app-level config.
- **Search** to find nodes fast.
- Open the **Shortcuts** modal for the full key list (Save `Ctrl/Cmd+S`, undo/redo, and the rest).
---
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
+32 -17
View File
@@ -11,18 +11,9 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
>
Generate a new hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
```
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
```
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
>
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
## Quick Start — Frontend only
@@ -53,13 +44,37 @@ docker compose up -d
## Proxmox LXC Install
You can now install Homelable with community-scripts (proxmox-VE) :
`https://community-scripts.org/scripts/homelable`
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
```
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
```bash
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
```
The backend runs as a systemd service, the frontend is served via nginx on port 80.
> To install manually inside an existing Debian/Ubuntu machine or LXC:
> ```bash
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
> ```
### Update (LXC)
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
```bash
sudo bash /opt/homelable/scripts/update.sh
```
Or directly from GitHub:
```bash
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
```
---
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Remy Jardinet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12 -245
View File
@@ -1,44 +1,13 @@
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" />&nbsp;Homelable</h1>
# Homelable
<p align="center">
<strong>Self-hosted homelab infrastructure visualization, scanning &amp; live monitoring</strong>
</p>
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
<p align="center">
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&amp;label=build" alt="Build status" /></a>
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
</p>
<p align="center">
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#installation">Installation</a> ·
<a href="#network-scanner">Network Scanner</a> ·
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
<a href="#proxmox-ve-import">Proxmox</a> ·
<a href="#live-view-read-only-public-canvas">Live View</a> ·
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
</p>
## About
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
You can also select some pre-built design styles, or personalize each device in your diagram.
If you just like the design, you can only run the frontend and export your design as PNG.
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
---
@@ -46,22 +15,13 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
<p align="center">
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
<img width="49.5%" alt="Homelable Custom node" src="https://github.com/user-attachments/assets/813725b1-376b-4bad-bb1f-0985f3bc7546" />
<img width="49.5%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/35e18d11-8363-498d-ae3d-642685cac76d" />
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
</p>
---
## Features
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
---
## Installation
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
@@ -77,20 +37,6 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
### Deep scan (custom ports)
By default the scanner only probes nmap's standard port set. To fingerprint services on non-standard ports, enable the deep scan via `.env` (all options are overridable per-scan from the scan dialog):
```env
# JSON array of port specs — each entry is a single port "N" or an inclusive
# range "N-M" (165535, N <= M). These are ports, not CIDRs or bare integers.
SCANNER_HTTP_RANGES=["8080","9000-9100"]
SCANNER_HTTP_PROBE_ENABLED=true # send an HTTP probe to those ports for service ID
SCANNER_HTTP_VERIFY_TLS=false # verify TLS certs on the HTTP probe
```
The listed ports are appended to nmap's `-p` spec. Invalid entries (out-of-range, malformed, or reversed ranges) are silently skipped.
### macOS / root privileges
Some nmap scan types (SYN scan, OS detection) require root. If the scan fails with a permissions error, run it manually with sudo using the included script:
@@ -128,180 +74,7 @@ Homelable continuously monitors your nodes and displays their live status (onlin
---
## Zigbee2MQTT Import
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
### Prerequisites
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
- **Zigbee2MQTT** connected to the broker with at least one device paired
### Usage
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
### Node Types
| Type | Z2M Device | Icon |
|------|-----------|------|
| `zigbee_coordinator` | Coordinator | Network hub |
| `zigbee_router` | Router (mains-powered) | Radio |
| `zigbee_enddevice` | End Device (battery) | Antenna |
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
LQI (Link Quality Indicator) is stored as a node property.
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
---
## Z-Wave Import
Homelable can also import your **Z-Wave** network from **Z-Wave JS UI** (formerly `zwavejs2mqtt`) over the same MQTT broker, dropping each node on the canvas as a typed node.
### Prerequisites
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
- **Z-Wave JS UI** connected to the broker with its MQTT gateway enabled and at least one node included
### Usage
1. Click **Z-Wave Import** in the left sidebar (below "Zigbee Import")
2. Enter your broker host, port (default `1883`), optional credentials, MQTT prefix (default `zwave`), and gateway name (default `zwavejs2mqtt`)
3. Click **Test Connection** to verify reachability
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Devices**
5. Select the devices you want from the grouped list (Controller / Router / End Device) and click **Add N to Canvas**
### Node Types
| Type | Z-Wave Role | Icon |
|------|-------------|------|
| `zwave_coordinator` | Controller | Network hub |
| `zwave_router` | Routing (mains-powered) node | Radio |
| `zwave_enddevice` | End Device (battery) | Antenna |
Hierarchy is set automatically: controller → routers → end devices (`parent_id`), derived from each node's neighbor list. Z-Wave has no LQI, so that property is omitted.
> **Full documentation:** [docs/zwave-import.md](./docs/zwave-import.md)
---
## Proxmox VE Import
Homelable can import your **Proxmox VE** inventory over the Proxmox REST API — hosts, VMs and LXC containers arrive as typed, named nodes with run state and hardware specs, and can auto-sync on a schedule. Guest IPs that were already found by a network scan are merged in place (no duplicates).
### Prerequisites
- A reachable **Proxmox VE** host (default API port `8006`)
- A **Proxmox API token** with the read-only **`PVEAuditor`** role (Datacenter → Permissions → API Tokens)
### Usage
1. Click **Proxmox Import** in the left sidebar (below "Z-Wave Import")
2. Enter the host, port (default `8006`), and API token (`user@realm!tokenid` + secret) — or leave the token blank to use the server-configured one
3. Click **Test Connection** to verify reachability + token
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Inventory**
5. Select the devices from the grouped list (Hosts / Virtual Machines / LXC Containers) and click **Add N to Canvas**
### Node Types
| Type | Proxmox object | Icon |
|------|----------------|------|
| `proxmox` | Host / cluster member | Layers |
| `vm` | QEMU virtual machine | Box |
| `lxc` | LXC container | Container |
Each host is linked to its guests with a `virtual` edge. vCPU / RAM / disk are imported as node properties (hidden by default). Enable **auto-sync** from Settings once a server token is configured (`PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET`).
> **Full documentation:** [docs/proxmox-import.md](./docs/proxmox-import.md)
---
## Live View (read-only public canvas)
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
### Activation
Add LIVEVIEW_KEY to your .env:
`LIVEVIEW_KEY=your-secret-key`
Then restart the backend:
`docker compose restart backend`
### Usage
Use this URL to view your canvas:
http://<your-homelab-ip>/view?key=your-secret-key
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
---
## Gethomepage Widget (read-only stats)
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
### Activation
Add `HOMEPAGE_API_KEY` to your `.env`:
`HOMEPAGE_API_KEY=your-secret-key`
Restart the backend (`docker compose restart backend`).
### Endpoint
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
```json
{
"nodes": 12,
"online": 9,
"offline": 2,
"unknown": 1,
"pending_devices": 3,
"zigbee_devices": 5,
"last_scan_at": "2026-05-14T10:00:00+00:00"
}
```
### gethomepage `services.yaml` snippet
```yaml
- Homelab:
- Homelable:
icon: mdi-lan
href: http://homelable.local:3000
widget:
type: customapi
url: http://homelable.local:8000/api/v1/stats/summary
method: GET
headers:
X-API-Key: your-secret-key
mappings:
- field: nodes ; label: Nodes
- field: online ; label: Online
- field: offline ; label: Offline
- field: pending_devices ; label: Pending
- field: zigbee_devices ; label: Zigbee
- field: last_scan_at ; label: Last scan
```
The backend port (`8000`) must be reachable from your gethomepage container.
---
## MCP Server (AI Integration) (optional)
## MCP Server (AI Integration) (optionnal)
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
@@ -336,17 +109,11 @@ docker compose up -d mcp
# MCP server is now listening on http://<your-homelab-ip>:8001
```
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
**3. Configure your AI client:**
**Claude Code** — run this command in your terminal:
```bash
claude mcp add --transport http homelable http://<your-homelab-ip>:8001/mcp/ \
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
--header "X-API-Key: mcp_sk_yourkey"
```
@@ -355,8 +122,8 @@ Or add it manually to `~/.claude.json`:
{
"mcpServers": {
"homelable": {
"type": "http",
"url": "http://<your-homelab-ip>:8001/mcp/",
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
@@ -370,8 +137,8 @@ Or add it manually to `~/.claude.json`:
{
"mcpServers": {
"homelable": {
"type": "http",
"url": "http://<your-homelab-ip>:8001/mcp/",
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
-1
View File
@@ -1 +0,0 @@
3.1.1
+18 -48
View File
@@ -1,14 +1,13 @@
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
@@ -17,57 +16,33 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def load_canvas(
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> CanvasStateResponse:
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
# A CanvasState row exists only after a save (or explicit design create),
# so its presence marks an intentional canvas vs. a never-touched one.
initialized=state is not None,
)
@router.post("/save")
async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool | str]:
design_id = body.design_id
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
db.add(new_design)
await db.flush()
design_id = new_design.id
) -> dict[str, bool]:
incoming_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas (only within this design)
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
# Delete nodes removed from canvas
existing_nodes = (await db.execute(select(Node))).scalars().all()
for node in existing_nodes:
if node.id not in incoming_node_ids:
await db.delete(node)
# Delete edges removed from canvas (only within this design)
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
# Delete edges removed from canvas
existing_edges = (await db.execute(select(Edge))).scalars().all()
for edge in existing_edges:
if edge.id not in incoming_edge_ids:
await db.delete(edge)
@@ -77,33 +52,28 @@ async def save_canvas(
# Upsert nodes
for node_data in body.nodes:
db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
if db_node:
for field, value in payload.items():
for field, value in node_data.model_dump().items():
setattr(db_node, field, value)
else:
db.add(Node(**payload))
db.add(Node(**node_data.model_dump()))
# Upsert edges
for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id)
payload = edge_data.model_dump()
payload["design_id"] = design_id
if db_edge:
for field, value in payload.items():
for field, value in edge_data.model_dump().items():
setattr(db_edge, field, value)
else:
db.add(Edge(**payload))
db.add(Edge(**edge_data.model_dump()))
# Upsert viewport + custom style
state = await db.get(CanvasState, design_id)
# Upsert viewport
state = await db.get(CanvasState, 1)
if state:
state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
db.add(CanvasState(id=1, viewport=body.viewport))
await db.commit()
return {"saved": True}
-179
View File
@@ -1,179 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
# Node.type values that are canvas annotations rather than real devices. Kept in
# sync with the frontend (Sidebar counts, canvasSerializer types).
_GROUP_TYPE = "groupRect"
_TEXT_TYPE = "text"
@router.get("", response_model=list[DesignResponse])
async def list_designs(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[DesignResponse]:
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
# One grouped query for all designs → node/group/text counts per design.
rows = (
await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type))
).all()
counts: dict[str, dict[str, int]] = {}
for design_id, node_type, count in rows:
if design_id is None:
continue
bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0})
if node_type == _GROUP_TYPE:
bucket["group"] += count
elif node_type == _TEXT_TYPE:
bucket["text"] += count
else:
bucket["node"] += count
result = []
for d in designs:
resp = DesignResponse.model_validate(d)
c = counts.get(d.id, {"node": 0, "group": 0, "text": 0})
resp.node_count = c["node"]
resp.group_count = c["group"]
resp.text_count = c["text"]
result.append(resp)
return result
@router.post("", response_model=DesignResponse, status_code=201)
async def create_design(
body: DesignCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
db.add(design)
await db.flush()
# Create empty canvas state for the new design
db.add(CanvasState(design_id=design.id))
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201)
async def copy_design(
source_id: str,
body: DesignCopy,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
"""Create a new design that deep-copies the source's nodes, edges and canvas state."""
source = await db.get(Design, source_id)
if not source:
raise HTTPException(404, "Source design not found")
new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type)
db.add(new_design)
await db.flush()
src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all()
src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all()
# New id per source node so edges and parent links can be re-pointed at the copy.
id_map = {n.id: str(uuid.uuid4()) for n in src_nodes}
# Columns we set explicitly or let the DB default — never copy verbatim.
node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"}
for n in src_nodes:
cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip}
db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols))
await db.flush() # nodes must exist before we wire self-referential parent_id
# Second pass: re-point parent links inside the copy.
for n in src_nodes:
if n.parent_id and n.parent_id in id_map:
child = await db.get(Node, id_map[n.id])
if child:
child.parent_id = id_map[n.parent_id]
edge_skip = {"id", "design_id", "source", "target", "created_at"}
for e in src_edges:
# Skip edges whose endpoints aren't part of this design (dangling FKs).
if e.source not in id_map or e.target not in id_map:
continue
cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip}
db.add(
Edge(
id=str(uuid.uuid4()),
design_id=new_design.id,
source=id_map[e.source],
target=id_map[e.target],
**cols,
)
)
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
src_state = await db.get(CanvasState, source_id)
db.add(
CanvasState(
design_id=new_design.id,
viewport=src_state.viewport if src_state else {},
custom_style=src_state.custom_style if src_state else None,
)
)
await db.commit()
await db.refresh(new_design)
return DesignResponse.model_validate(new_design)
@router.put("/{design_id}", response_model=DesignResponse)
async def update_design(
design_id: str,
body: DesignUpdate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
if body.name is not None:
design.name = body.name
if body.icon is not None:
design.icon = body.icon
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.delete("/{design_id}", status_code=204)
async def delete_design(
design_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> None:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
# Count remaining designs — prevent deleting the last one
count = (await db.execute(select(Design))).scalars().all()
if len(count) <= 1:
raise HTTPException(400, "Cannot delete the only design")
# Delete associated canvas state, edges, nodes
cs = await db.get(CanvasState, design_id)
if cs:
await db.delete(cs)
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for e in edges:
await db.delete(e)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for n in nodes:
await db.delete(n)
await db.delete(design)
await db.commit()
+2 -66
View File
@@ -4,56 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Edge, Node
from app.db.models import Edge
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
router = APIRouter()
# ---------------------------------------------------------------------------
# Auto-handle helpers
# ---------------------------------------------------------------------------
async def _abs_y(db: AsyncSession, node_id: str) -> float | None:
"""Resolve the approximate absolute canvas Y of a node.
Walks up the parent chain (up to 8 levels) and accumulates pos_y offsets so
that children inside containers are compared correctly against top-level nodes.
Returns None when the node is not found.
"""
node = await db.get(Node, node_id)
if node is None:
return None
y = node.pos_y
current = node
for _ in range(8):
if current.parent_id is None:
break
parent = await db.get(Node, current.parent_id)
if parent is None:
break
y += parent.pos_y
current = parent
return y
async def _auto_handles(
db: AsyncSession, source_id: str, target_id: str
) -> tuple[str, str]:
"""Return (source_handle, target_handle) that reflect the upstream/downstream
relationship between two nodes.
- Source above target (lower Y value) → downstream flow: exit bottom, enter top
- Source below target → upstream flow: exit top, enter bottom
- Equal or unknown → default to bottom/top-t (most common topology direction)
"""
src_y = await _abs_y(db, source_id)
tgt_y = await _abs_y(db, target_id)
if src_y is None or tgt_y is None or src_y <= tgt_y:
return "bottom", "top-t"
return "top", "bottom-t"
@router.get("", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
@@ -63,26 +18,7 @@ async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
data = body.model_dump()
# Same reconciliation as nodes: clients omitting design_id (MCP write tools)
# would create design_id=null edges that never render until a restart.
# Fall back to the first design so the edge attaches to a canvas.
if data.get("design_id") is None:
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
data["design_id"] = first_design.id if first_design else None
# Auto-assign source/target handles when the caller omits them.
# Compares the canvas Y positions of both nodes so that the edge always exits
# the upstream node's bottom and enters the downstream node's top (or vice versa
# for reverse flows), matching the UI convention for top-to-bottom topologies.
if data.get("source_handle") is None or data.get("target_handle") is None:
auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"])
if data.get("source_handle") is None:
data["source_handle"] = auto_src
if data.get("target_handle") is None:
data["target_handle"] = auto_tgt
edge = Edge(**data)
edge = Edge(**body.model_dump())
db.add(edge)
await db.commit()
await db.refresh(edge)
-72
View File
@@ -1,72 +0,0 @@
import hmac
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
router = APIRouter()
class LiveViewConfigResponse(BaseModel):
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
enabled: bool
key: str | None = None
@router.get("/config", response_model=LiveViewConfigResponse)
async def liveview_config(
_: str = Depends(get_current_user),
) -> LiveViewConfigResponse:
"""Authenticated: expose the configured live view key so the UI can build a
ready-to-use share link (e.g. /view?key=...&design=<id>).
Only reachable by a logged-in user — the key is never exposed publicly.
"""
key = settings.liveview_key or None
return LiveViewConfigResponse(enabled=bool(key), key=key)
@router.get("", response_model=CanvasStateResponse)
async def liveview_canvas(
key: str | None = Query(default=None),
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse:
"""Read-only public canvas endpoint.
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
Always returns 403 when disabled, regardless of the key provided.
"""
if not settings.liveview_key:
raise HTTPException(status_code=403, detail="Live view is disabled")
if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key")
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=custom_style,
)
-100
View File
@@ -1,100 +0,0 @@
"""Generic media upload/serve endpoint.
Images are stored on disk (see `Settings.media_dir`) with server-generated
UUID filenames — never in the DB, and the client filename is never trusted.
Upload/delete require auth; GET is public so plain <img> tags and the read-only
live view can load images (filenames are unguessable).
Currently used by the floor-plan feature; kept deliberately generic so future
raw-image uploads reuse the same endpoint.
"""
import re
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from app.api.deps import get_current_user
from app.core.config import settings
router = APIRouter()
# content-type → extension. Also the allowlist of accepted uploads.
ALLOWED_TYPES: dict[str, str] = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
_MAGIC: dict[str, tuple[bytes, ...]] = {
".png": (b"\x89PNG\r\n\x1a\n",),
".jpg": (b"\xff\xd8\xff",),
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
}
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
# Only ever serve/delete files we created: 32 hex chars + known extension.
_NAME_RE = re.compile(r"[0-9a-f]{32}\.(png|jpg|webp)")
def _resolve_media_path(filename: str) -> Path:
"""Return the existing media file named `filename`, or raise 404.
`filename` is validated against `_NAME_RE` (no separators, no `..`), then
matched by name against the actual directory listing. The user string is
only ever compared with `==` against trusted `iterdir()` entries — it never
builds a path — so a crafted value cannot escape the media dir.
"""
if not _NAME_RE.fullmatch(filename):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
base = settings.media_dir()
if base.is_dir():
for entry in base.iterdir():
if entry.name == filename and entry.is_file():
return entry
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
@router.post("/upload")
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
ext = ALLOWED_TYPES.get(file.content_type or "")
if ext is None:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Unsupported media type — PNG, JPEG, or WebP only",
)
# Read one byte past the cap so we can detect oversize without loading more.
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail="File too large (max 10 MB)",
)
if not data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="File content does not match its type",
)
media_dir = settings.media_dir()
media_dir.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}{ext}"
(media_dir / name).write_bytes(data)
return {"filename": name, "url": f"/api/v1/media/{name}"}
@router.get("/{filename}")
async def get_media(filename: str) -> FileResponse:
# _resolve_media_path returns only an existing file, else raises 404.
return FileResponse(_resolve_media_path(filename))
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
_resolve_media_path(filename).unlink()
+2 -81
View File
@@ -4,55 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Node
from app.db.models import Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
from app.services.node_dedupe import find_duplicate_node
router = APIRouter()
# ---------------------------------------------------------------------------
# Auto-positioning helpers
# ---------------------------------------------------------------------------
# Canvas grid used when placing nodes without explicit coordinates.
# Each slot is wide/tall enough that a normal node card fits without overlap.
_SLOT_W = 200.0
_SLOT_H = 100.0
_MAX_COLS = 7
async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[float, float]:
"""Return (x, y) for a new root-level node that doesn't collide with existing ones.
Snaps existing root nodes to a virtual grid and returns the first unoccupied
cell, scanning left-to-right then top-to-bottom.
"""
result = await db.execute(
select(Node.pos_x, Node.pos_y).where(
Node.design_id == design_id,
Node.parent_id.is_(None),
)
)
positions = list(result.all())
if not positions:
return 0.0, 0.0
# Snap each existing node to its true grid cell (negatives kept as-is). The
# search below only visits col >= 0 / row >= 0, so nodes parked in negative
# space never falsely block — or falsely free — a positive slot.
occupied: set[tuple[int, int]] = set()
for (px, py) in positions:
col = round(px / _SLOT_W)
row = round(py / _SLOT_H)
occupied.add((col, row))
for row in range(10_000):
for col in range(_MAX_COLS):
if (col, row) not in occupied:
return col * _SLOT_W, row * _SLOT_H
return 0.0, 0.0 # unreachable in practice
@router.get("", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
@@ -62,42 +18,7 @@ async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
data = body.model_dump()
# `force` bypasses the duplicate guard below; it is not a Node column.
force = data.pop("force", False)
# Attach to a design so the node lands on a canvas. Clients that don't send a
# design_id (e.g. the MCP write tools) would otherwise create design_id=null
# nodes that exist in the DB but never render in the UI until a container
# restart reconciles them. Fall back to the first design, matching bulk-approve.
if data.get("design_id") is None:
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
data["design_id"] = first_design.id if first_design else None
# Reject a silent duplicate: a node with the same ip OR mac already on the
# target design. Scripts/MCP clients get a clear 409 (with the existing id)
# instead of a second card for the same host. Pass force=True to override.
if not force:
dup = await find_duplicate_node(db, data["design_id"], data.get("ip"), data.get("mac"))
if dup is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=dup)
# Auto-position: when pos_x / pos_y are omitted (None), find a free canvas
# slot so the new node doesn't land on top of an existing one.
# Child nodes (parent_id set) use (0, 0) relative to their parent instead.
if data["pos_x"] is None or data["pos_y"] is None:
if data.get("parent_id") is None:
auto_x, auto_y = await _find_free_position(db, data["design_id"])
if data["pos_x"] is None:
data["pos_x"] = auto_x
if data["pos_y"] is None:
data["pos_y"] = auto_y
else:
if data["pos_x"] is None:
data["pos_x"] = 0.0
if data["pos_y"] is None:
data["pos_y"] = 0.0
node = Node(**data)
node = Node(**body.model_dump())
db.add(node)
await db.commit()
await db.refresh(node)
-512
View File
@@ -1,512 +0,0 @@
"""FastAPI router for Proxmox VE import + auto-sync config.
Fetches hosts/VMs/LXC from the Proxmox REST API and upserts them into the
pending inventory (same review→approve flow as scans and mesh imports).
Credentials: the API token comes from the request body when provided, else
falls back to the server-configured env token (``settings.proxmox_token_*``).
The token is never persisted by the app and never returned by any endpoint.
"""
import logging
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete as sa_delete
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.proxmox import (
ProxmoxConfig,
ProxmoxConnectionRequest,
ProxmoxEdgeOut,
ProxmoxImportPendingResponse,
ProxmoxImportResponse,
ProxmoxNodeOut,
ProxmoxSyncConfig,
ProxmoxTestConnectionResponse,
)
from app.schemas.scan import ScanRunResponse
from app.services.discovery_sources import add_source
from app.services.mac_utils import normalize_mac
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.proxmox_service import (
build_proxmox_cluster_links,
build_proxmox_properties,
fetch_proxmox_inventory,
merge_proxmox_properties,
test_proxmox_connection,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# Discovery sources for the two proxmox link shapes. Host→guest links render as
# 'virtual' edges; host↔host cluster links render as 'cluster' edges.
_PROXMOX_GUEST_SOURCE = "proxmox"
_PROXMOX_CLUSTER_SOURCE = "proxmox_cluster"
def _resolve_credentials(payload: ProxmoxConnectionRequest) -> tuple[str, str]:
"""Pick the API token: request body first, else server env config.
Raises HTTP 400 when neither carries a token.
"""
token_id = payload.token_id or settings.proxmox_token_id
token_secret = payload.token_secret or settings.proxmox_token_secret
if not token_id or not token_secret:
raise HTTPException(
status_code=400,
detail="No Proxmox API token provided and none configured on the server.",
)
return token_id, token_secret
@router.post("/test-connection", response_model=ProxmoxTestConnectionResponse)
async def test_connection_endpoint(
payload: ProxmoxConnectionRequest,
_: str = Depends(get_current_user),
) -> ProxmoxTestConnectionResponse:
"""Validate host reachability + token before importing."""
token_id, token_secret = _resolve_credentials(payload)
connected, message = await test_proxmox_connection(
host=payload.host,
port=payload.port,
token_id=token_id,
token_secret=token_secret,
verify_tls=payload.verify_tls,
)
return ProxmoxTestConnectionResponse(connected=connected, message=message)
@router.post("/import", response_model=ProxmoxImportResponse)
async def import_proxmox(
payload: ProxmoxConnectionRequest,
_: str = Depends(get_current_user),
) -> ProxmoxImportResponse:
"""Fetch the inventory and return nodes + edges ready for canvas drop."""
token_id, token_secret = _resolve_credentials(payload)
try:
nodes_raw, edges_raw = await fetch_proxmox_inventory(
host=payload.host,
port=payload.port,
token_id=token_id,
token_secret=token_secret,
verify_tls=payload.verify_tls,
)
except ConnectionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Unexpected error during Proxmox import")
raise HTTPException(status_code=500, detail="Unexpected error during Proxmox import") from exc
nodes = [ProxmoxNodeOut(**n) for n in nodes_raw]
edges = [ProxmoxEdgeOut(**e) for e in edges_raw]
return ProxmoxImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
@router.post("/import-pending", response_model=ScanRunResponse)
async def import_proxmox_to_pending(
payload: ProxmoxConnectionRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Queue a Proxmox pending import as a background scan run (kind=proxmox)."""
token_id, token_secret = _resolve_credentials(payload)
run = ScanRun(
status="running",
kind="proxmox",
ranges=[f"{payload.host}:{payload.port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(
_background_proxmox_import,
run.id,
payload.host,
payload.port,
token_id,
token_secret,
payload.verify_tls,
)
return run
@router.post("/sync-now", response_model=ScanRunResponse)
async def sync_proxmox_now(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Trigger an immediate Proxmox inventory sync using the server env config.
Same background flow as ``/import-pending`` but sources host + token from
``settings`` (env) rather than the request body — the manual counterpart to
the scheduled auto-sync job. Requires the env token to be configured.
"""
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
raise HTTPException(
status_code=400,
detail="Cannot sync: no Proxmox host/token configured on the server.",
)
run = ScanRun(
status="running",
kind="proxmox",
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(
_background_proxmox_import,
run.id,
settings.proxmox_host,
settings.proxmox_port,
settings.proxmox_token_id,
settings.proxmox_token_secret,
settings.proxmox_verify_tls,
)
return run
async def _background_proxmox_import(
run_id: str,
host: str,
port: int,
token_id: str,
token_secret: str,
verify_tls: bool,
) -> None:
async with AsyncSessionLocal() as db:
try:
nodes_raw, edges_raw = await fetch_proxmox_inventory(
host=host,
port=port,
token_id=token_id,
token_secret=token_secret,
verify_tls=verify_tls,
)
result = await _persist_pending_import(db, nodes_raw, edges_raw)
run = await db.get(ScanRun, run_id)
if run:
run.status = "done"
run.devices_found = result.device_count
run.finished_at = datetime.now(timezone.utc)
run.error = _guest_visibility_advisory(nodes_raw)
await db.commit()
# Nudge the frontend to reload the inventory (same signal the IP scan
# emits) so imported/merged devices appear without a manual refresh.
from app.api.routes.status import broadcast_scan_update
await broadcast_scan_update(run_id=run_id, devices_found=result.device_count)
except Exception as exc:
logger.exception("Proxmox import %s failed", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run:
run.status = "error"
run.error = str(exc)[:500]
run.finished_at = datetime.now(timezone.utc)
await db.commit()
def _guest_visibility_advisory(nodes_raw: list[dict[str, Any]]) -> str | None:
"""Non-fatal advisory when hosts import but no VMs/LXC were visible.
A Proxmox API token that lacks ``VM.Audit`` sees empty ``qemu``/``lxc`` lists
(HTTP 200, no error), so only host nodes come through. The usual cause is a
privilege-separated token whose effective rights are the *intersection* with
the user's rights — granting PVEAuditor to the token alone is not enough when
the user has none. Surface that instead of a silent success.
"""
hosts = sum(1 for n in nodes_raw if n.get("type") == "proxmox")
guests = len(nodes_raw) - hosts
if hosts and not guests:
return (
f"Imported {hosts} host(s) but no VMs or LXC were visible to the API "
"token. Grant the PVEAuditor role at path '/' to BOTH the token and "
"the user (privilege-separated tokens get the intersection of token "
"and user rights), then re-import."
)
return None
async def _persist_pending_import(
db: AsyncSession,
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ProxmoxImportPendingResponse:
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
Two-tier identity (order matters):
1. Match an existing canvas Node or pending row by **IP** (merge into a
device previously found by a scan) — never duplicate.
2. Else match by synthetic ``ieee_address`` (``pve-...``).
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
"""
await dedupe_nodes_by_ieee(db)
cluster_pairs = build_proxmox_cluster_links(nodes_raw)
cluster_members = {ieee for pair in cluster_pairs for ieee in pair}
pending_created = 0
pending_updated = 0
for n in nodes_raw:
ieee = n.get("ieee_address")
if not ieee:
continue
ip = n.get("ip")
mac = normalize_mac(n.get("mac"))
props = build_proxmox_properties(n)
# 1) Already on a canvas? Match by ieee OR ip OR mac (the cross-source
# dedup key — a stopped VM has no IP but its configured NIC MAC still
# matches an ARP-scanned node). Refresh in place: merge properties, adopt
# the pve identity onto a scanned node, backfill blank specs/hostname/mac.
# Do NOT stomp user-set type/status.
node_filter = [Node.ieee_address == ieee]
if ip:
node_filter.append(Node.ip == ip)
if mac:
node_filter.append(Node.mac == mac)
existing_nodes = (
await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
).scalars().all()
if existing_nodes:
for en in existing_nodes:
en.properties = merge_proxmox_properties(en.properties, props)
if not en.ieee_address:
en.ieee_address = ieee
if ip and not en.ip:
en.ip = ip
if mac and not en.mac:
en.mac = mac
en.hostname = en.hostname or n.get("hostname")
en.cpu_count = en.cpu_count or n.get("cpu_count")
en.ram_gb = en.ram_gb or n.get("ram_gb")
en.disk_gb = en.disk_gb or n.get("disk_gb")
# A cluster host needs one left + one right handle for the
# cluster edge endpoints (both default to 0).
if ieee in cluster_members:
en.left_handles = max(en.left_handles or 0, 1)
en.right_handles = max(en.right_handles or 0, 1)
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
pending_updated += 1
continue
# 2) Not on canvas — upsert the pending inventory row.
pending = await _find_pending(db, ieee, ip, mac)
if pending is None:
db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
pending_created += 1
else:
_refresh_pending(pending, ieee, ip, mac, n, props)
pending_updated += 1
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
await db.commit()
return ProxmoxImportPendingResponse(
pending_created=pending_created,
pending_updated=pending_updated,
links_recorded=links_recorded,
device_count=len(nodes_raw),
)
async def _find_pending(
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
) -> PendingDevice | None:
filters = [PendingDevice.ieee_address == ieee]
if ip:
filters.append(PendingDevice.ip == ip)
if mac:
filters.append(PendingDevice.mac == mac)
return (
await db.execute(select(PendingDevice).where(or_(*filters)))
).scalars().first()
def _new_pending(
ieee: str,
ip: str | None,
mac: str | None,
n: dict[str, Any],
props: list[dict[str, Any]],
status: str,
) -> PendingDevice:
return PendingDevice(
ieee_address=ieee,
ip=ip,
mac=mac,
hostname=n.get("hostname"),
friendly_name=n.get("label"),
suggested_type=n.get("type"),
vendor=n.get("vendor"),
model=n.get("model"),
properties=props,
status=status,
discovery_source=_PROXMOX_GUEST_SOURCE,
discovery_sources=[_PROXMOX_GUEST_SOURCE],
)
def _sources_after_merge(row: PendingDevice) -> list[str]:
"""Discovery sources for an inventory row after a Proxmox import merges in.
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
whether the row was originally a scanned device. Preserves the prior scan
origin — including legacy rows created before ``discovery_sources`` existed
(empty list) and possibly with a NULL ``discovery_source`` — so the IP tag
survives the merge. A row that carries an IP but was not itself a Proxmox
device (no ``pve-`` ieee) was found by a scan; keep an IP-scan source.
"""
sources = add_source(row.discovery_sources, row.discovery_source)
was_scanned = not (row.ieee_address or "").startswith("pve-")
if was_scanned and row.ip and not any(s in ("arp", "mdns") for s in sources):
sources = add_source(sources, "arp")
return add_source(sources, _PROXMOX_GUEST_SOURCE)
def _refresh_pending(
pending: PendingDevice,
ieee: str,
ip: str | None,
mac: str | None,
n: dict[str, Any],
props: list[dict[str, Any]],
) -> None:
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
pending.discovery_sources = _sources_after_merge(pending)
pending.ieee_address = pending.ieee_address or ieee
pending.ip = ip or pending.ip
pending.mac = pending.mac or mac
pending.hostname = n.get("hostname") or pending.hostname
pending.friendly_name = n.get("label") or pending.friendly_name
pending.suggested_type = n.get("type") or pending.suggested_type
pending.vendor = n.get("vendor") or pending.vendor
pending.model = n.get("model") or pending.model
pending.properties = merge_proxmox_properties(list(pending.properties or []), props)
if pending.status == "approved":
# Approved earlier but the canvas Node is gone — revive so it reappears.
pending.status = "pending"
# hidden stays hidden.
async def _ensure_inventory_row(
db: AsyncSession,
ieee: str,
ip: str | None,
mac: str | None,
n: dict[str, Any],
props: list[dict[str, Any]],
approved: bool,
) -> None:
"""Ensure an inventory row exists for a device already on a canvas, so it
shows in the inventory with an 'In N canvas' badge. Never changes status."""
inv = await _find_pending(db, ieee, ip, mac)
if inv is None:
db.add(_new_pending(ieee, ip, mac, n, props, status="approved" if approved else "pending"))
else:
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
inv.discovery_sources = _sources_after_merge(inv)
inv.ieee_address = inv.ieee_address or ieee
inv.ip = ip or inv.ip
inv.mac = inv.mac or mac
inv.hostname = n.get("hostname") or inv.hostname
inv.suggested_type = n.get("type") or inv.suggested_type
inv.properties = merge_proxmox_properties(list(inv.properties or []), props)
async def _replace_links(
db: AsyncSession,
edges_raw: list[dict[str, Any]],
cluster_pairs: list[tuple[str, str]],
) -> int:
"""Wipe all proxmox-source links and re-insert the freshly discovered set.
Two link shapes: host→guest (``proxmox`` → 'virtual' edges) and host↔host
(``proxmox_cluster`` → 'cluster' edges).
"""
await db.execute(
sa_delete(PendingDeviceLink).where(
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
)
)
recorded = 0
seen: set[tuple[str, str]] = set()
def _add(src: str | None, tgt: str | None, source: str) -> None:
nonlocal recorded
if not src or not tgt or (src, tgt) in seen:
return
seen.add((src, tgt))
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
recorded += 1
for e in edges_raw:
_add(e.get("source"), e.get("target"), _PROXMOX_GUEST_SOURCE)
for src, tgt in cluster_pairs:
_add(src, tgt, _PROXMOX_CLUSTER_SOURCE)
return recorded
@router.get("/config", response_model=ProxmoxConfig)
async def get_proxmox_config(_: str = Depends(get_current_user)) -> ProxmoxConfig:
"""Return non-secret Proxmox config. Never includes the token — only whether
one is configured on the server."""
return ProxmoxConfig(
host=settings.proxmox_host,
port=settings.proxmox_port,
verify_tls=settings.proxmox_verify_tls,
sync_enabled=settings.proxmox_sync_enabled,
sync_interval=settings.proxmox_sync_interval,
token_configured=bool(settings.proxmox_token_id and settings.proxmox_token_secret),
)
@router.post("/config", response_model=ProxmoxConfig)
async def save_proxmox_config(
payload: ProxmoxSyncConfig,
_: str = Depends(get_current_user),
) -> ProxmoxConfig:
"""Persist the auto-sync activation (enabled + interval) and apply it live.
This is the ONLY Proxmox config the app writes. Connection settings
(host, port, token, verify_tls) are env-only and are never accepted or
persisted here — enabling auto-sync requires host + token already set in the
server env, since the scheduled job reads them from there.
"""
if payload.sync_enabled and not (
settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret
):
raise HTTPException(
status_code=400,
detail="Cannot enable auto-sync: no Proxmox host/token configured in the server env.",
)
try:
settings.proxmox_sync_enabled = payload.sync_enabled
settings.proxmox_sync_interval = payload.sync_interval
settings.save_overrides()
set_proxmox_sync_enabled(payload.sync_enabled)
if payload.sync_enabled:
reschedule_proxmox_sync(payload.sync_interval)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return await get_proxmox_config()
+20 -713
View File
@@ -1,527 +1,59 @@
import ipaddress
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, field_validator
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import Node, PendingDevice, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.services.zigbee_service import (
build_zigbee_properties,
merge_zigbee_properties,
)
from app.services.zwave_service import build_zwave_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
def _ip_tokens(ip: str | None) -> list[str]:
"""Split a Node/device ``ip`` field into individual addresses.
The canvas stores multiple addresses in one comma-separated string (e.g.
``"fe80::1, 192.168.1.5"`` once a user adds an IPv6 address). Matching a
scanned device against that field must compare per-address, not against the
whole string, or the device looks absent from the canvas (issue #258).
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
def _is_wireless(node_type: str | None) -> bool:
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
def _wireless_properties(
node_type: str | None,
ieee: str | None,
vendor: str | None,
model: str | None,
lqi: int | None,
) -> list[dict[str, Any]]:
"""Build the right property rows for a mesh device (Z-Wave has no LQI)."""
if node_type in _ZWAVE_TYPES:
return build_zwave_properties(ieee, vendor, model)
return build_zigbee_properties(ieee, vendor, model, lqi)
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
"""Build a NodeProperty list carrying a device MAC address.
Shape matches the frontend ``NodeProperty`` type
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
showing it on the canvas card from the right panel. Returns an empty list
when no MAC is known.
"""
if not mac:
return []
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
def merge_mac_property(
props: list[dict[str, Any]] | None, mac: str | None
) -> list[dict[str, Any]]:
"""Append a MAC NodeProperty to ``props`` unless one is already present.
Preserves any user-supplied properties (and an existing MAC row's
visibility) untouched. Used on approve so the scanned MAC is not lost.
"""
out = [dict(p) for p in (props or [])]
if not mac or any(p.get("key") == "MAC" for p in out):
return out
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
return out
class BulkActionRequest(BaseModel):
device_ids: list[str]
# Target design for approved nodes. Falls back to the first design when
# omitted (keeps older clients working), but the UI should send the active
# design so approved devices land on the canvas the user is looking at.
design_id: str | None = None
def _check_port_ranges(v: list[str]) -> list[str]:
for r in v:
if not _valid_port_range(r.strip()):
raise ValueError(f"Invalid port range: {r!r}")
return v
from app.services.scanner import run_scan
class ScanConfig(BaseModel):
"""Persisted scan defaults (Options page). Deep-scan fields are optional."""
ranges: list[str]
http_ranges: list[str] = []
http_probe_enabled: bool = False
verify_tls: bool = False
@field_validator("ranges")
@classmethod
def validate_cidr(cls, v: list[str]) -> list[str]:
for r in v:
try:
ipaddress.ip_network(r, strict=False)
except ValueError as exc:
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
return v
@field_validator("http_ranges")
@classmethod
def validate_http_ranges(cls, v: list[str]) -> list[str]:
return _check_port_ranges(v)
class TriggerScanRequest(BaseModel):
"""Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
http_ranges: list[str] | None = None
http_probe_enabled: bool | None = None
verify_tls: bool | None = None
@field_validator("http_ranges")
@classmethod
def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
return None if v is None else _check_port_ranges(v)
interval_seconds: int
logger = logging.getLogger(__name__)
router = APIRouter()
async def _background_scan(
run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
) -> None:
async def _background_scan(run_id: str, ranges: list[str]) -> None:
async with AsyncSessionLocal() as db:
try:
await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run and run.status == "running":
run.status = "failed"
await db.commit()
def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
"""Merge per-scan overrides over persisted settings defaults."""
p = payload or TriggerScanRequest()
return DeepScanOptions(
http_ranges=(
p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
),
http_probe_enabled=(
p.http_probe_enabled
if p.http_probe_enabled is not None
else settings.scanner_http_probe_enabled
),
verify_tls=(
p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
),
)
await run_scan(ranges, db, run_id)
@router.post("/trigger", response_model=ScanRunResponse)
async def trigger_scan(
background_tasks: BackgroundTasks,
payload: TriggerScanRequest | None = None,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
ranges = settings.scanner_ranges
deep_scan = _resolve_deep_scan(payload)
run = ScanRun(status="running", ranges=ranges)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
background_tasks.add_task(_background_scan, run.id, ranges)
return run
@router.post("/{run_id}/stop", response_model=dict)
async def stop_scan(
run_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
try:
uuid.UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
run = await db.get(ScanRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Scan run not found")
if run.status != "running":
raise HTTPException(status_code=409, detail="Scan is not running")
request_cancel(run_id)
# Flip status eagerly so the UI reflects the stop immediately, instead of
# waiting for run_scan to reach its next cancellation checkpoint (which may
# be blocked inside a long nmap call). run_scan converges to the same state.
run.status = "cancelled"
run.finished_at = datetime.now(timezone.utc)
await db.commit()
return {"stopping": True}
def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
"""Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
present = [v for v in values if v is not None]
if not present:
return None
return max(present) if newest else min(present)
async def _canvas_correlation(
db: AsyncSession, devices: list[PendingDevice]
) -> dict[str, dict[str, Any]]:
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
or ``ip``.
Returns, per device id: the number of distinct canvases (designs) it appears
on, plus aggregated timestamps from every matching node — created_at (oldest),
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
(node counts are small for a homelab), so no N+1 per device.
IP matching is per-address: a node's ``ip`` may hold several comma-separated
addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
the raw string. MAC is a stable identifier immune to such IP edits, so it is
matched too — cumulatively with ieee/ip (issue #258).
"""
if not devices:
return {}
rows = (
await db.execute(
select(
Node.ip,
Node.mac,
Node.ieee_address,
Node.design_id,
Node.created_at,
Node.last_scan,
Node.updated_at,
Node.last_seen,
).where(Node.design_id.isnot(None))
)
).all()
# Index matching nodes by ip token, mac and ieee so a device can look up any.
by_ip: dict[str, list[Any]] = {}
by_mac: dict[str, list[Any]] = {}
by_ieee: dict[str, list[Any]] = {}
for row in rows:
for tok in _ip_tokens(row.ip):
by_ip.setdefault(tok, []).append(row)
if row.mac:
by_mac.setdefault(row.mac, []).append(row)
if row.ieee_address:
by_ieee.setdefault(row.ieee_address, []).append(row)
info: dict[str, dict[str, Any]] = {}
for d in devices:
matched = []
if d.ieee_address:
matched += by_ieee.get(d.ieee_address, [])
if d.mac:
matched += by_mac.get(d.mac, [])
for tok in _ip_tokens(d.ip):
matched += by_ip.get(tok, [])
# De-duplicate nodes matched by more than one identifier.
matched = list({id(m): m for m in matched}.values())
designs = {m.design_id for m in matched}
info[d.id] = {
"canvas_count": len(designs),
"node_created_at": _agg([m.created_at for m in matched], newest=False),
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
}
return info
async def _with_canvas_counts(
db: AsyncSession, devices: list[PendingDevice]
) -> list[PendingDevice]:
"""Attach transient canvas count + linked-node timestamps for the response."""
info = await _canvas_correlation(db, devices)
for d in devices:
meta = info.get(d.id, {})
d.canvas_count = meta.get("canvas_count", 0)
d.node_created_at = meta.get("node_created_at")
d.node_last_scan = meta.get("node_last_scan")
d.node_last_modified = meta.get("node_last_modified")
d.node_last_seen = meta.get("node_last_seen")
return devices
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
# Inventory: every scanned device except the user-hidden ones. Approved devices
# stay listed so they keep showing with a canvas-presence badge.
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.delete("/pending", response_model=dict)
async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
from sqlalchemy import delete as sa_delete
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
await db.commit()
return {"deleted": result.rowcount}
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
return list(result.scalars().all())
@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
# Repair any legacy same-canvas duplicate nodes before placing more.
await dedupe_nodes_by_ieee(db)
# Target the design the user is on; fall back to the first design.
default_design_id = payload.design_id
if default_design_id is None:
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
# Accept every selected device that isn't user-hidden. We intentionally do NOT
# filter on status == "pending": a device's status is global, but canvas
# membership is per-design. A device approved onto another canvas (or whose
# node was later deleted) must still be placeable on THIS design. Duplicates
# are guarded per-design below, not by the global status flag.
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status != "hidden",
)
)
devices = result.scalars().all()
# What already sits on the target canvas, so we skip devices already placed
# here (by ip, mac or ieee_address) instead of creating duplicate nodes. We
# map to the existing node id so the skip report can point the user at it. A
# value may be a Node still pending flush (in-batch duplicate) — resolved to
# its id after the flush below. IPs are indexed per comma-separated token so
# a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
# 192.168.1.5 (issue #258).
existing = (
await db.execute(
select(Node.id, Node.ip, Node.mac, Node.ieee_address).where(
Node.design_id == default_design_id
)
)
).all()
placed_ips: dict[str, Any] = {
tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
}
placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
created_nodes: list[Node] = []
approved_devices: list[PendingDevice] = []
skipped_devices: list[dict[str, Any]] = []
for device in devices:
# Record which identifier collided so the caller can explain each skip
# (and, for existing on-canvas nodes, link to the node already there).
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
if ip_hit is not None:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.ip or "device",
"match": "ip", "value": ip_hit, "_ref": placed_ips[ip_hit],
})
continue
if device.ieee_address is not None and device.ieee_address in placed_ieee:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.ieee_address or "device",
"match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
})
continue
if device.mac is not None and device.mac in placed_mac:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.mac or "device",
"match": "mac", "value": device.mac, "_ref": placed_mac[device.mac],
})
continue
device.status = "approved"
node_type = device.suggested_type or "generic"
is_wireless = _is_wireless(node_type)
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type,
ip=device.ip,
mac=device.mac,
hostname=device.hostname,
status="online" if is_wireless else "unknown",
services=device.services or [],
ieee_address=device.ieee_address,
properties=_wireless_properties(
node_type, device.ieee_address, device.vendor, device.model, device.lqi
) if is_wireless else merge_mac_property(list(device.properties or []), device.mac),
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_wireless else ("ping" if device.ip else None),
# Cluster hosts get side handles for their host↔host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=default_design_id,
)
db.add(node)
created_nodes.append(node)
approved_devices.append(device)
# Track within this batch so a duplicate selection (same ip/mac/ieee) is
# not placed twice on the same canvas. Store the Node so a later in-batch
# skip can resolve to its id after flush.
for tok in _ip_tokens(device.ip):
placed_ips[tok] = node
if device.mac:
placed_mac[device.mac] = node
if device.ieee_address:
placed_ieee[device.ieee_address] = node
await db.flush() # populates node.id from Python-side default before reading
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in approved_devices]
# Resolve each skip's existing-node reference to a concrete id now that any
# in-batch nodes have been flushed, and expose it under a clean key.
for entry in skipped_devices:
ref = entry.pop("_ref")
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
all_edges: list[dict[str, str]] = []
for device in approved_devices:
all_edges.extend(
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
)
await db.commit()
return {
"approved": len(node_ids),
"node_ids": node_ids,
"device_ids": approved_device_ids,
"edges_created": len(all_edges),
"edges": all_edges,
"skipped": len(payload.device_ids) - len(node_ids),
"skipped_devices": skipped_devices,
}
@router.post("/pending/bulk-hide", response_model=dict)
async def bulk_hide_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "hidden"
await db.commit()
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/restore", response_model=dict)
async def restore_device(
device_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "hidden":
raise HTTPException(status_code=409, detail="Device is not hidden")
device.status = "pending"
await db.commit()
return {"restored": True, "device_id": device_id}
@router.post("/pending/bulk-restore", response_model=dict)
async def bulk_restore_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "hidden",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "pending"
await db.commit()
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
return list(result.scalars().all())
@router.post("/pending/{device_id}/approve", response_model=dict)
@@ -531,220 +63,14 @@ async def approve_device(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
# Determine target design
node_design_id = node_data.design_id
if node_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
node_design_id = first.id if first else None
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
# design must therefore work even when the device already sits on another
# one (mirroring bulk_approve_devices, which deliberately does not filter on
# status == "pending"). Same-design duplicates are caught by the per-design
# IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
# device is off-limits here.
if device.status == "hidden":
raise HTTPException(status_code=409, detail="Device is hidden")
wireless = _is_wireless(node_data.type)
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
# again automatically: the user might genuinely want a second card, or might
# be re-approving by mistake. Reject with 409 + the existing node so the UI
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
# hosts. force=True (set after the user confirms) skips this and creates it.
# The same device on a *different* design is valid (one Node per canvas), so
# this is scoped to node_design_id.
if not node_data.force:
conflict = await find_duplicate_node(
db, node_design_id,
node_data.ip or device.ip,
node_data.mac or device.mac,
ieee=device.ieee_address,
)
if conflict is not None:
raise HTTPException(status_code=409, detail=conflict)
if device:
device.status = "approved"
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
mac=_mac,
hostname=node_data.hostname,
status="online" if wireless else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
properties=_wireless_properties(
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
) if wireless else merge_mac_property(
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
_mac,
),
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if wireless else node_data.check_target,
# Cluster hosts get side handles for their host↔host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=node_design_id,
)
node = Node(**node_data.model_dump())
db.add(node)
await db.flush()
node_id = node.id
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)
await db.commit()
return {
"approved": True,
"node_id": node_id,
"edges_created": len(edges),
"edges": edges,
}
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
Such a host needs one left + one right handle for the cluster edge endpoints
(both default to 0). Checked at approve time, before the link is consumed by
``_resolve_pending_links_for_ieee``.
"""
if not ieee:
return False
found = (
await db.execute(
select(PendingDeviceLink.id)
.where(
PendingDeviceLink.discovery_source == "proxmox_cluster",
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
)
.limit(1)
)
).scalar()
return found is not None
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None, design_id: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee`` on the
canvas identified by ``design_id``.
For each link where the other endpoint already exists as a Node *on this
design* (matched by ``Node.ieee_address`` + ``Node.design_id``), create the
Edge. Links are **never** deleted here: they describe the discovered mesh /
cluster topology and are wiped+reinserted wholesale on the next import
(zigbee/zwave/proxmox). Keeping them lets the same devices be re-approved
onto a second canvas with their edges intact.
"""
if not ieee:
return []
links_q = await db.execute(
select(PendingDeviceLink).where(
(PendingDeviceLink.source_ieee == ieee)
| (PendingDeviceLink.target_ieee == ieee)
)
)
links = list(links_q.scalars().all())
if not links:
return []
# Map every relevant ieee → Node *on the target design* (single query).
# Scoping by design is what makes a re-approve onto a second canvas link the
# nodes of THAT canvas, not stale nodes left on another one.
other_ieees = {
link.target_ieee if link.source_ieee == ieee else link.source_ieee
for link in links
}
other_ieees.add(ieee)
nodes_q = await db.execute(
select(Node).where(
Node.ieee_address.in_(other_ieees),
Node.design_id == design_id,
)
)
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
self_node = by_ieee.get(ieee)
if self_node is None:
return []
# Pre-fetch existing edges between these node ids so we don't create dups
# if the user re-approves a device or had drawn the link manually.
candidate_node_ids = [n.id for n in by_ieee.values()]
existing_q = await db.execute(
select(Edge).where(
Edge.source.in_(candidate_node_ids),
Edge.target.in_(candidate_node_ids),
)
)
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
created: list[dict[str, str]] = []
for link in links:
other_ieee = (
link.target_ieee if link.source_ieee == ieee else link.source_ieee
)
other_node = by_ieee.get(other_ieee)
if other_node is None:
continue
if link.source_ieee == ieee:
src_id, tgt_id = self_node.id, other_node.id
else:
src_id, tgt_id = other_node.id, self_node.id
# Skip if either direction already exists on this design (re-approve or
# a manually drawn link). The link row is kept for other designs.
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
continue
# Edge lands on the design we're approving into (the nodes above are
# already scoped to it).
edge_design_id = design_id or (self_node.design_id if self_node else None)
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
# (the canonical stored form — the save path normalizes '<side>-t' → the
# bare source id, and React Flow resolves the bare id to that side). A
# '-t' target id does not resolve here and RF falls back to the top
# handle, so never emit one.
# proxmox → 'virtual' host→guest, vertical (bottom → top)
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
# anything else → 'iot' mesh link, vertical
if link.discovery_source == "proxmox":
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
elif link.discovery_source == "proxmox_cluster":
edge_type, src_handle, tgt_handle = "cluster", "right", "left"
else:
edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
edge = Edge(
source=src_id,
target=tgt_id,
type=edge_type,
source_handle=src_handle,
target_handle=tgt_handle,
design_id=edge_design_id,
)
db.add(edge)
await db.flush()
existing_pairs.add((src_id, tgt_id))
# Return the edge's type + handles so the client injects it faithfully
# (a cluster edge must keep its right→left handles, not the iot default).
created.append({
"id": edge.id,
"source": src_id,
"target": tgt_id,
"type": edge_type,
"source_handle": src_handle,
"target_handle": tgt_handle,
})
return created
return {"approved": True, "node_id": node.id}
return {"approved": False}
@router.post("/pending/{device_id}/hide")
@@ -752,8 +78,7 @@ async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device:
device.status = "hidden"
await db.commit()
return {"hidden": True}
@@ -764,8 +89,7 @@ async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device:
await db.delete(device)
await db.commit()
return {"ignored": True}
@@ -781,33 +105,16 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
return ScanConfig(
ranges=settings.scanner_ranges,
http_ranges=settings.scanner_http_ranges,
http_probe_enabled=settings.scanner_http_probe_enabled,
verify_tls=settings.scanner_http_verify_tls,
interval_seconds=settings.status_checker_interval,
)
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
previous = (
settings.scanner_ranges,
settings.scanner_http_ranges,
settings.scanner_http_probe_enabled,
settings.scanner_http_verify_tls,
)
settings.scanner_ranges = payload.ranges
settings.scanner_http_ranges = payload.http_ranges
settings.scanner_http_probe_enabled = payload.http_probe_enabled
settings.scanner_http_verify_tls = payload.verify_tls
try:
settings.scanner_ranges = payload.ranges
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides()
return payload
except Exception as exc:
(
settings.scanner_ranges,
settings.scanner_http_ranges,
settings.scanner_http_probe_enabled,
settings.scanner_http_verify_tls,
) = previous
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
raise HTTPException(status_code=500, detail=str(exc)) from exc
-42
View File
@@ -1,42 +0,0 @@
"""App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
router = APIRouter()
class AppSettings(BaseModel):
interval_seconds: int
service_check_enabled: bool = False
service_check_interval: int = Field(default=300, ge=30)
@router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
return AppSettings(
interval_seconds=settings.status_checker_interval,
service_check_enabled=settings.service_check_enabled,
service_check_interval=settings.service_check_interval,
)
@router.post("", response_model=AppSettings)
async def update_settings(
payload: AppSettings, _: str = Depends(get_current_user)
) -> AppSettings:
try:
settings.status_checker_interval = payload.interval_seconds
settings.service_check_enabled = payload.service_check_enabled
settings.service_check_interval = payload.service_check_interval
settings.save_overrides()
# Apply the service-check schedule live.
set_service_checks_enabled(payload.service_check_enabled)
if payload.service_check_enabled:
reschedule_service_checks(payload.service_check_interval)
return payload
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
-64
View File
@@ -1,64 +0,0 @@
import hmac
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import get_db
from app.db.models import Node, PendingDevice, ScanRun
router = APIRouter()
def _check_key(x_api_key: str | None) -> None:
if not settings.homepage_api_key:
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
raise HTTPException(status_code=403, detail="Invalid API key")
@router.get("/summary")
async def summary(
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> dict[str, object]:
"""Read-only stats payload for the gethomepage `customapi` widget.
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
value in the `X-API-Key` header.
"""
_check_key(x_api_key)
status_rows = (
await db.execute(select(Node.status, func.count()).group_by(Node.status))
).all()
counts = {row[0]: row[1] for row in status_rows}
pending = (
await db.execute(
select(func.count())
.select_from(PendingDevice)
.where(PendingDevice.status == "pending")
)
).scalar_one()
zigbee = (
await db.execute(
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
)
).scalar_one()
last_scan_at = (
await db.execute(select(func.max(ScanRun.finished_at)))
).scalar_one()
return {
"nodes": sum(counts.values()),
"online": counts.get("online", 0),
"offline": counts.get("offline", 0),
"unknown": counts.get("unknown", 0),
"pending_devices": pending,
"zigbee_devices": zigbee,
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
}
+2 -22
View File
@@ -1,4 +1,3 @@
import contextlib
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -11,12 +10,6 @@ router = APIRouter()
_connections: list[WebSocket] = []
def _drop(websocket: WebSocket) -> None:
"""Remove a connection if still present — idempotent, never raises."""
with contextlib.suppress(ValueError):
_connections.remove(websocket)
@router.websocket("/ws/status")
async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code
@@ -40,11 +33,7 @@ async def ws_status(websocket: WebSocket) -> None:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
# Any error (disconnect or otherwise) must release the slot, else the
# dead socket lingers in the broadcast pool.
_drop(websocket)
_connections.remove(websocket)
async def _broadcast(payload: str) -> None:
@@ -52,7 +41,7 @@ async def _broadcast(payload: str) -> None:
try:
await conn.send_text(payload)
except Exception:
_drop(conn)
_connections.remove(conn)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
@@ -65,15 +54,6 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
}))
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
await _broadcast(json.dumps({
"type": "service_status",
"node_id": node_id,
"services": services,
"checked_at": checked_at,
}))
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
await _broadcast(json.dumps({
"type": "scan_device_found",
-407
View File
@@ -1,407 +0,0 @@
"""FastAPI router for Zigbee2MQTT import."""
import logging
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import (
ZigbeeConfig,
ZigbeeCoordinatorOut,
ZigbeeEdgeOut,
ZigbeeImportPendingResponse,
ZigbeeImportRequest,
ZigbeeImportResponse,
ZigbeeNodeOut,
ZigbeeSyncConfig,
ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse,
)
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.zigbee_service import (
build_zigbee_properties,
fetch_networkmap,
merge_zigbee_properties,
test_mqtt_connection,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/import", response_model=ZigbeeImportResponse)
async def import_zigbee_network(
payload: ZigbeeImportRequest,
_: str = Depends(get_current_user),
) -> ZigbeeImportResponse:
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
Connects to the specified MQTT broker, publishes a networkmap request to
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
coordinator → router → end-device hierarchy.
"""
try:
nodes_raw, edges_raw = await fetch_networkmap(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
base_topic=payload.base_topic,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except ConnectionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except TimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Unexpected error during Zigbee import")
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
@router.post("/import-pending", response_model=ScanRunResponse)
async def import_zigbee_to_pending(
payload: ZigbeeImportRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Queue a Zigbee2MQTT pending import as a background scan run.
Returns the ScanRun row immediately so the UI can close the import
modal and surface progress under Scan History (kind=zigbee). The
actual MQTT fetch + pending upsert happens in the background.
"""
run = ScanRun(
status="running",
kind="zigbee",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zigbee_import, run.id, payload)
return run
def env_import_request() -> ZigbeeImportRequest:
"""Build an import request from the server env config (for auto-sync).
MQTT credentials live in the env only — never in the request body or any
API response. The scheduled auto-sync job and ``/sync-now`` both source
their connection settings here so there is a single source of truth."""
return ZigbeeImportRequest(
mqtt_host=settings.zigbee_mqtt_host,
mqtt_port=settings.zigbee_mqtt_port,
mqtt_username=settings.zigbee_mqtt_username or None,
mqtt_password=settings.zigbee_mqtt_password or None,
base_topic=settings.zigbee_base_topic,
mqtt_tls=settings.zigbee_mqtt_tls,
mqtt_tls_insecure=settings.zigbee_mqtt_tls_insecure,
)
@router.post("/sync-now", response_model=ScanRunResponse)
async def sync_zigbee_now(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Trigger an immediate Zigbee import using the server env config.
Same background flow as ``/import-pending`` but sources the MQTT connection
from ``settings`` (env) rather than the request body — the manual
counterpart to the scheduled auto-sync job. Requires the env host to be set.
"""
if not settings.zigbee_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot sync: no Zigbee MQTT host configured on the server.",
)
payload = env_import_request()
run = ScanRun(
status="running",
kind="zigbee",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zigbee_import, run.id, payload)
return run
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
async with AsyncSessionLocal() as db:
try:
nodes_raw, edges_raw = await fetch_networkmap(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
base_topic=payload.base_topic,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
result = await _persist_pending_import(db, nodes_raw, edges_raw)
run = await db.get(ScanRun, run_id)
if run:
run.status = "done"
run.devices_found = result.device_count
run.finished_at = datetime.now(timezone.utc)
await db.commit()
except Exception as exc:
logger.exception("Zigbee import %s failed", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run:
run.status = "error"
run.error = str(exc)[:500]
run.finished_at = datetime.now(timezone.utc)
await db.commit()
async def _persist_pending_import(
db: AsyncSession,
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZigbeeImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
All zigbee-source links are wiped and re-inserted from the new map.
"""
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
# the by-IEEE lookups below resolve to a single row.
await dedupe_nodes_by_ieee(db)
# Coordinator is no longer auto-placed, so the response's coordinator fields
# stay unset — retained for backward-compatible response shape.
coordinator_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False
pending_created = 0
pending_updated = 0
for n in nodes_raw:
ieee = n.get("ieee_address")
if not ieee:
continue
props = build_zigbee_properties(
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
)
# The coordinator is no longer auto-placed on the canvas — it flows to
# the pending inventory like every other device, so the user approves it
# explicitly. Only the shared paths below run for it.
# If the device has already been approved as a canvas Node, refresh its
# properties on every canvas it sits on. Still ensure the discovery
# inventory carries a row for it (status="approved") so it shows in the
# inventory list with an "In N canvas" badge — legacy auto-placed
# coordinators never got a pending row, which is why they went missing.
existing_nodes = (
await db.execute(
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
)
).scalars().all()
if existing_nodes:
for existing_node in existing_nodes:
existing_node.properties = merge_zigbee_properties(
existing_node.properties, props
)
inv = (
await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zigbee",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status (an approved
# device stays approved; a hidden one stays hidden).
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="pending",
discovery_source="zigbee",
)
)
pending_created += 1
else:
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
pending.suggested_type = n.get("type") or pending.suggested_type
pending.device_subtype = n.get("device_type") or pending.device_subtype
pending.model = n.get("model") or pending.model
pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None:
pending.lqi = n.get("lqi")
if pending.status == "approved":
# The device was approved earlier but its canvas Node no longer
# exists (no Node matched the IEEE above) — it was deleted. Revive
# the row to "pending" so it reappears in the Pending list on
# re-import instead of being silently swallowed. (Issue #167)
pending.status = "pending"
elif pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields.
pass
pending_updated += 1
# Replace all zigbee-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
)
links_recorded = 0
seen: set[tuple[str, str]] = set()
for e in edges_raw:
src = e.get("source")
tgt = e.get("target")
if not src or not tgt or (src, tgt) in seen:
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zigbee",
)
)
links_recorded += 1
await db.commit()
return ZigbeeImportPendingResponse(
pending_created=pending_created,
pending_updated=pending_updated,
coordinator=coordinator_out,
coordinator_already_existed=coordinator_existed,
links_recorded=links_recorded,
device_count=len(nodes_raw),
)
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
async def test_zigbee_connection(
payload: ZigbeeTestConnectionRequest,
_: str = Depends(get_current_user),
) -> ZigbeeTestConnectionResponse:
"""Quick MQTT ping to validate broker connection before importing."""
try:
await test_mqtt_connection(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except (ConnectionError, TimeoutError) as exc:
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
except Exception:
logger.exception("Unexpected error during connection test")
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
@router.get("/config", response_model=ZigbeeConfig)
async def get_zigbee_config(_: str = Depends(get_current_user)) -> ZigbeeConfig:
"""Return non-secret Zigbee config. Never includes MQTT credentials — only
whether a host is configured on the server for auto-sync."""
return ZigbeeConfig(
mqtt_host=settings.zigbee_mqtt_host,
mqtt_port=settings.zigbee_mqtt_port,
base_topic=settings.zigbee_base_topic,
mqtt_tls=settings.zigbee_mqtt_tls,
sync_enabled=settings.zigbee_sync_enabled,
sync_interval=settings.zigbee_sync_interval,
host_configured=bool(settings.zigbee_mqtt_host),
)
@router.post("/config", response_model=ZigbeeConfig)
async def save_zigbee_config(
payload: ZigbeeSyncConfig,
_: str = Depends(get_current_user),
) -> ZigbeeConfig:
"""Persist the auto-sync activation (enabled + interval) and apply it live.
This is the ONLY Zigbee config the app writes. Connection settings
(host/port/credentials/topic/tls) are env-only and are never accepted or
persisted here — enabling auto-sync requires the MQTT host already set in
the server env, since the scheduled job reads it from there.
"""
if payload.sync_enabled and not settings.zigbee_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot enable auto-sync: no Zigbee MQTT host configured in the server env.",
)
try:
settings.zigbee_sync_enabled = payload.sync_enabled
settings.zigbee_sync_interval = payload.sync_interval
settings.save_overrides()
set_zigbee_sync_enabled(payload.sync_enabled)
if payload.sync_enabled:
reschedule_zigbee_sync(payload.sync_interval)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return await get_zigbee_config()
-397
View File
@@ -1,397 +0,0 @@
"""FastAPI router for Z-Wave JS UI (zwavejs2mqtt) import."""
import logging
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zwave import (
ZwaveConfig,
ZwaveCoordinatorOut,
ZwaveEdgeOut,
ZwaveImportPendingResponse,
ZwaveImportRequest,
ZwaveImportResponse,
ZwaveNodeOut,
ZwaveSyncConfig,
ZwaveTestConnectionRequest,
ZwaveTestConnectionResponse,
)
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.zwave_service import (
build_zwave_properties,
fetch_zwave_network,
merge_zwave_properties,
test_zwave_connection,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/import", response_model=ZwaveImportResponse)
async def import_zwave_network(
payload: ZwaveImportRequest,
_: str = Depends(get_current_user),
) -> ZwaveImportResponse:
"""Fetch the Z-Wave node list and return nodes + edges ready for canvas drop.
Connects to the broker, publishes a ``getNodes`` request to the Z-Wave JS UI
gateway, and waits for the response. Devices are returned as typed homelable
nodes with a coordinator → router → end-device hierarchy.
"""
try:
nodes_raw, edges_raw = await fetch_zwave_network(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
prefix=payload.prefix,
gateway_name=payload.gateway_name,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except ConnectionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except TimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Unexpected error during Z-Wave import")
raise HTTPException(status_code=500, detail="Unexpected error during Z-Wave import") from exc
nodes = [ZwaveNodeOut(**n) for n in nodes_raw]
edges = [ZwaveEdgeOut(**e) for e in edges_raw]
return ZwaveImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
@router.post("/import-pending", response_model=ScanRunResponse)
async def import_zwave_to_pending(
payload: ZwaveImportRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Queue a Z-Wave pending import as a background scan run (kind=zwave)."""
run = ScanRun(
status="running",
kind="zwave",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zwave_import, run.id, payload)
return run
def env_import_request() -> ZwaveImportRequest:
"""Build an import request from the server env config (for auto-sync).
MQTT credentials live in the env only — never in the request body or any
API response. The scheduled auto-sync job and ``/sync-now`` both source
their connection settings here so there is a single source of truth."""
return ZwaveImportRequest(
mqtt_host=settings.zwave_mqtt_host,
mqtt_port=settings.zwave_mqtt_port,
mqtt_username=settings.zwave_mqtt_username or None,
mqtt_password=settings.zwave_mqtt_password or None,
prefix=settings.zwave_prefix,
gateway_name=settings.zwave_gateway_name,
mqtt_tls=settings.zwave_mqtt_tls,
mqtt_tls_insecure=settings.zwave_mqtt_tls_insecure,
)
@router.post("/sync-now", response_model=ScanRunResponse)
async def sync_zwave_now(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Trigger an immediate Z-Wave import using the server env config.
Same background flow as ``/import-pending`` but sources the MQTT connection
from ``settings`` (env) rather than the request body — the manual
counterpart to the scheduled auto-sync job. Requires the env host to be set.
"""
if not settings.zwave_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot sync: no Z-Wave MQTT host configured on the server.",
)
payload = env_import_request()
run = ScanRun(
status="running",
kind="zwave",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zwave_import, run.id, payload)
return run
async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
async with AsyncSessionLocal() as db:
try:
nodes_raw, edges_raw = await fetch_zwave_network(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
prefix=payload.prefix,
gateway_name=payload.gateway_name,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
result = await _persist_pending_import(db, nodes_raw, edges_raw)
run = await db.get(ScanRun, run_id)
if run:
run.status = "done"
run.devices_found = result.device_count
run.finished_at = datetime.now(timezone.utc)
await db.commit()
except Exception as exc:
logger.exception("Z-Wave import %s failed", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run:
run.status = "error"
run.error = str(exc)[:500]
run.finished_at = datetime.now(timezone.utc)
await db.commit()
async def _persist_pending_import(
db: AsyncSession,
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZwaveImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
identity. All zwave-source links are wiped and re-inserted from the new map.
"""
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
# the by-IEEE lookups below resolve cleanly.
await dedupe_nodes_by_ieee(db)
# Coordinator is no longer auto-placed, so the response's coordinator fields
# stay unset — retained for backward-compatible response shape.
coordinator_out: ZwaveCoordinatorOut | None = None
coordinator_existed = False
pending_created = 0
pending_updated = 0
for n in nodes_raw:
ieee = n.get("ieee_address")
if not ieee:
continue
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
# The coordinator is no longer auto-placed on the canvas — it flows to
# the pending inventory like every other device, so the user approves it
# explicitly. Only the shared paths below run for it.
# Already approved as a canvas Node → refresh props on every canvas it
# sits on. Still ensure the discovery inventory carries a row for it
# (status="approved") so it shows in the inventory list with an
# "In N canvas" badge — legacy auto-placed coordinators never got a
# pending row, which is why they went missing.
existing_nodes = (
await db.execute(
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
)
).scalars().all()
if existing_nodes:
for existing_node in existing_nodes:
existing_node.properties = merge_zwave_properties(
existing_node.properties, props
)
inv = (
await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zwave",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status.
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="pending",
discovery_source="zwave",
)
)
pending_created += 1
else:
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
pending.suggested_type = n.get("type") or pending.suggested_type
pending.device_subtype = n.get("device_type") or pending.device_subtype
pending.model = n.get("model") or pending.model
pending.vendor = n.get("vendor") or pending.vendor
if pending.status == "approved":
# Approved earlier but the canvas Node is gone (deleted) — revive
# to "pending" so it reappears in the list instead of vanishing.
pending.status = "pending"
elif pending.status == "hidden":
pass
pending_updated += 1
# Replace all zwave-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
)
links_recorded = 0
seen: set[tuple[str, str]] = set()
for e in edges_raw:
src = e.get("source")
tgt = e.get("target")
if not src or not tgt or (src, tgt) in seen:
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zwave",
)
)
links_recorded += 1
await db.commit()
return ZwaveImportPendingResponse(
pending_created=pending_created,
pending_updated=pending_updated,
coordinator=coordinator_out,
coordinator_already_existed=coordinator_existed,
links_recorded=links_recorded,
device_count=len(nodes_raw),
)
@router.post("/test-connection", response_model=ZwaveTestConnectionResponse)
async def test_connection_endpoint(
payload: ZwaveTestConnectionRequest,
_: str = Depends(get_current_user),
) -> ZwaveTestConnectionResponse:
"""Quick MQTT ping to validate broker connection before importing."""
try:
await test_zwave_connection(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
return ZwaveTestConnectionResponse(connected=True, message="Connection successful")
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except (ConnectionError, TimeoutError) as exc:
return ZwaveTestConnectionResponse(connected=False, message=str(exc))
except Exception:
logger.exception("Unexpected error during connection test")
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
@router.get("/config", response_model=ZwaveConfig)
async def get_zwave_config(_: str = Depends(get_current_user)) -> ZwaveConfig:
"""Return non-secret Z-Wave config. Never includes MQTT credentials — only
whether a host is configured on the server for auto-sync."""
return ZwaveConfig(
mqtt_host=settings.zwave_mqtt_host,
mqtt_port=settings.zwave_mqtt_port,
prefix=settings.zwave_prefix,
gateway_name=settings.zwave_gateway_name,
mqtt_tls=settings.zwave_mqtt_tls,
sync_enabled=settings.zwave_sync_enabled,
sync_interval=settings.zwave_sync_interval,
host_configured=bool(settings.zwave_mqtt_host),
)
@router.post("/config", response_model=ZwaveConfig)
async def save_zwave_config(
payload: ZwaveSyncConfig,
_: str = Depends(get_current_user),
) -> ZwaveConfig:
"""Persist the auto-sync activation (enabled + interval) and apply it live.
This is the ONLY Z-Wave config the app writes. Connection settings
(host/port/credentials/prefix/gateway/tls) are env-only and are never
accepted or persisted here — enabling auto-sync requires the MQTT host
already set in the server env, since the scheduled job reads it from there.
"""
if payload.sync_enabled and not settings.zwave_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot enable auto-sync: no Z-Wave MQTT host configured in the server env.",
)
try:
settings.zwave_sync_enabled = payload.sync_enabled
settings.zwave_sync_interval = payload.sync_interval
settings.save_overrides()
set_zwave_sync_enabled(payload.sync_enabled)
if payload.sync_enabled:
reschedule_zwave_sync(payload.sync_interval)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return await get_zwave_config()
-146
View File
@@ -1,33 +1,14 @@
import json
import logging
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
Path("/app/VERSION"), # Docker image
]:
if candidate.exists():
return candidate.read_text().strip()
return "unknown"
APP_VERSION = _read_version()
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
secret_key: str # Required — set SECRET_KEY in .env
sqlite_path: str = "./data/homelab.db"
# Uploaded media (floor plans, and future raw image uploads) live on disk,
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
upload_dir: str = ""
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
# JWT
@@ -38,102 +19,20 @@ class Settings(BaseSettings):
auth_username: str = "admin"
auth_password_hash: str = ""
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
return self
# Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"]
# Phase-2 version-detection (-sV) host timeout, seconds. Bounds the version
# pass so a stalling TLS port (e.g. Proxmox 8006) can't hang it. Discovered
# ports survive a timeout regardless; raise this on slow/overlay networks.
scanner_version_host_timeout: int = 60
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
scanner_http_ranges: list[str] = []
scanner_http_probe_enabled: bool = False
scanner_http_verify_tls: bool = False
# Status checker
status_checker_interval: int = 60
# Per-service status checker (independent of node checks). Off by default.
service_check_enabled: bool = False
service_check_interval: int = 300
# MCP service key — set MCP_SERVICE_KEY in .env
# Used by the MCP server to authenticate against the backend without a user password.
# Leave empty to disable MCP service key auth.
mcp_service_key: str = ""
# Live view — optional read-only public canvas endpoint.
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
# Leave unset (or empty) to keep the feature disabled (default).
liveview_key: str | None = None
# Homepage widget — optional read-only stats endpoint for gethomepage.
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
# Leave empty to keep the feature disabled (default).
homepage_api_key: str = ""
# Proxmox VE import.
# Token = a real credential → env/.env ONLY, never persisted by the app to
# scan_config.json and never returned by the API. token_id is
# 'user@realm!tokenname'; use a read-only PVEAuditor role.
proxmox_token_id: str = ""
proxmox_token_secret: str = ""
# Non-secret connection + auto-sync config (persisted via save_overrides).
proxmox_host: str = ""
proxmox_port: int = 8006
proxmox_verify_tls: bool = True
proxmox_sync_enabled: bool = False
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
# Zigbee2MQTT auto-sync import.
# MQTT credentials are secrets → env/.env ONLY, never persisted by the app to
# scan_config.json and never returned by the API. Only the auto-sync
# activation (enabled + interval) is persisted; connection config is env-only.
zigbee_mqtt_host: str = ""
zigbee_mqtt_port: int = 1883
zigbee_mqtt_username: str = ""
zigbee_mqtt_password: str = ""
zigbee_base_topic: str = "zigbee2mqtt"
zigbee_mqtt_tls: bool = False
zigbee_mqtt_tls_insecure: bool = False
zigbee_sync_enabled: bool = False
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
zwave_mqtt_host: str = ""
zwave_mqtt_port: int = 1883
zwave_mqtt_username: str = ""
zwave_mqtt_password: str = ""
zwave_prefix: str = "zwave"
zwave_gateway_name: str = "zwavejs2mqtt"
zwave_mqtt_tls: bool = False
zwave_mqtt_tls_insecure: bool = False
zwave_sync_enabled: bool = False
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
def media_dir(self) -> Path:
"""On-disk folder for uploaded media. Sits on the same persistent
volume as the SQLite DB unless UPLOAD_DIR is set."""
if self.upload_dir:
return Path(self.upload_dir)
return Path(self.sqlite_path).parent / "uploads"
def load_overrides(self) -> None:
"""Load runtime scan config overrides written by the API."""
try:
@@ -142,35 +41,6 @@ class Settings(BaseSettings):
self.scanner_ranges = data["scanner_ranges"]
if "status_checker_interval" in data:
self.status_checker_interval = int(data["status_checker_interval"])
if "service_check_enabled" in data:
self.service_check_enabled = bool(data["service_check_enabled"])
if "service_check_interval" in data:
self.service_check_interval = int(data["service_check_interval"])
if "scanner_http_ranges" in data:
self.scanner_http_ranges = list(data["scanner_http_ranges"])
if "scanner_http_probe_enabled" in data:
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
if "scanner_http_verify_tls" in data:
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
# Proxmox auto-sync activation only. Connection config (host, port,
# token, verify_tls) is env-only by design — never read from or
# written to this file. Persisting host here previously created a
# dual source of truth that silently clobbered PROXMOX_HOST.
if "proxmox_sync_enabled" in data:
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
if "proxmox_sync_interval" in data:
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
# connection config (host, port, credentials, topic, tls) is env-only
# by design — never read from or written to this file.
if "zigbee_sync_enabled" in data:
self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"])
if "zigbee_sync_interval" in data:
self.zigbee_sync_interval = int(data["zigbee_sync_interval"])
if "zwave_sync_enabled" in data:
self.zwave_sync_enabled = bool(data["zwave_sync_enabled"])
if "zwave_sync_interval" in data:
self.zwave_sync_interval = int(data["zwave_sync_interval"])
except Exception:
pass
@@ -180,22 +50,6 @@ class Settings(BaseSettings):
self._override_path().write_text(json.dumps({
"scanner_ranges": self.scanner_ranges,
"status_checker_interval": self.status_checker_interval,
"service_check_enabled": self.service_check_enabled,
"service_check_interval": self.service_check_interval,
"scanner_http_ranges": self.scanner_http_ranges,
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
"scanner_http_verify_tls": self.scanner_http_verify_tls,
# Proxmox: only the auto-sync activation is persisted. Connection
# config (host, port, token, verify_tls) is env-only and must never
# be written to disk — that is the single source of truth.
"proxmox_sync_enabled": self.proxmox_sync_enabled,
"proxmox_sync_interval": self.proxmox_sync_interval,
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
# connection config (host/port/credentials/topic/tls) is env-only.
"zigbee_sync_enabled": self.zigbee_sync_enabled,
"zigbee_sync_interval": self.zigbee_sync_interval,
"zwave_sync_enabled": self.zwave_sync_enabled,
"zwave_sync_interval": self.zwave_sync_interval,
}))
+18 -352
View File
@@ -1,9 +1,6 @@
"""APScheduler setup for background scan and status check jobs."""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
@@ -11,381 +8,50 @@ from sqlalchemy import select
from app.core.config import settings
from app.db.database import AsyncSessionLocal
from app.db.models import Node
from app.services.status_checker import check_node, check_services
if TYPE_CHECKING:
from app.schemas.zigbee import ZigbeeImportRequest
from app.schemas.zwave import ZwaveImportRequest
from app.services.status_checker import check_node
logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler()
async def _check_single_node(
node_id: str,
check_method: str,
check_target: str | None,
ip: str | None,
) -> tuple[str, dict[str, object] | None]:
"""Run a single node check; returns (node_id, result_or_None).
Accepts plain scalars — not an ORM object — so there is no risk of
DetachedInstanceError when the originating session has already closed.
"""
async def _run_status_checks() -> None:
"""Check all nodes and broadcast results via WebSocket."""
from app.api.routes.status import broadcast_status # avoid circular import
try:
check_result = await check_node(check_method, check_target, ip)
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
n = await db.get(Node, node_id)
result = await db.execute(select(Node))
nodes = result.scalars().all()
for node in nodes:
if not node.check_method:
continue
try:
check_result = await check_node(node.check_method, node.check_target, node.ip)
async with AsyncSessionLocal() as db:
n = await db.get(Node, node.id)
if n:
n.status = check_result["status"]
n.response_time_ms = check_result["response_time_ms"]
if check_result["status"] == "online":
n.last_seen = now
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
await db.commit()
await broadcast_status(
node_id=node_id,
node_id=node.id,
status=check_result["status"],
checked_at=now.isoformat(),
checked_at=datetime.now(timezone.utc).isoformat(),
response_time_ms=check_result["response_time_ms"],
)
return node_id, check_result
except Exception as exc:
logger.error("Status check failed for node %s: %s", node_id, exc)
return node_id, None
async def _run_status_checks() -> None:
"""Check all nodes concurrently and broadcast results via WebSocket."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Node))
nodes = result.scalars().all()
# Extract scalars while the session is open to avoid DetachedInstanceError
checkable = [
(n.id, n.check_method, n.check_target, n.ip)
for n in nodes
if n.check_method
]
if not checkable:
return
await asyncio.gather(*[
_check_single_node(node_id, method, target, ip)
for node_id, method, target, ip in checkable
])
def _node_host(ip: str | None, hostname: str | None) -> str | None:
"""Pick the address to probe services on: first IP, else hostname."""
if ip:
first = ip.split(",")[0].strip()
if first:
return first
return hostname or None
async def _run_service_checks() -> None:
"""Check every service of every node and broadcast per-service results."""
if not settings.service_check_enabled:
return
from app.api.routes.status import broadcast_service_status # avoid circular import
async with AsyncSessionLocal() as db:
result = await db.execute(select(Node))
nodes = result.scalars().all()
checkable = [
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
for n in nodes
if n.services
]
now = datetime.now(timezone.utc).isoformat()
for node_id, host, services in checkable:
try:
statuses = await check_services(host, services)
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
except Exception as exc:
logger.error("Service checks failed for node %s: %s", node_id, exc)
async def _run_proxmox_sync() -> None:
"""Fetch the Proxmox inventory and upsert it into pending (auto-sync).
Records a ScanRun (kind=proxmox) so the scheduled sync shows in Scan
history, exactly like the manual /sync-now and /import-pending paths.
"""
if not settings.proxmox_sync_enabled:
return
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
logger.warning("Proxmox auto-sync enabled but host/token not configured — skipping")
return
# Lazy import to avoid a circular import at module load.
from app.api.routes.proxmox import _background_proxmox_import
from app.db.models import ScanRun
async with AsyncSessionLocal() as db:
run = ScanRun(
status="running",
kind="proxmox",
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
run_id = run.id
# Shares the manual-sync flow: fetch + persist + mark the run done/error +
# broadcast the inventory-reload signal.
await _background_proxmox_import(
run_id,
settings.proxmox_host,
settings.proxmox_port,
settings.proxmox_token_id,
settings.proxmox_token_secret,
settings.proxmox_verify_tls,
)
async def _run_mesh_sync(kind: str) -> None:
"""Shared auto-sync for the MQTT mesh imports (Zigbee / Z-Wave).
Records a ScanRun (kind=zigbee|zwave) so the scheduled sync shows in Scan
history, then delegates to the exact same background import the manual
/sync-now and /import-pending paths use. Connection config comes from the
server env only (credentials never leave it).
"""
from app.db.models import ScanRun
payload: ZigbeeImportRequest | ZwaveImportRequest
background: Callable[[str, Any], Awaitable[None]]
if kind == "zigbee":
if not settings.zigbee_sync_enabled:
return
if not settings.zigbee_mqtt_host:
logger.warning("Zigbee auto-sync enabled but MQTT host not configured — skipping")
return
from app.api.routes.zigbee import _background_zigbee_import
from app.api.routes.zigbee import env_import_request as _zigbee_env_request
host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port
payload = _zigbee_env_request()
background = _background_zigbee_import
else:
if not settings.zwave_sync_enabled:
return
if not settings.zwave_mqtt_host:
logger.warning("Z-Wave auto-sync enabled but MQTT host not configured — skipping")
return
from app.api.routes.zwave import _background_zwave_import
from app.api.routes.zwave import env_import_request as _zwave_env_request
host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port
payload = _zwave_env_request()
background = _background_zwave_import
async with AsyncSessionLocal() as db:
run = ScanRun(status="running", kind=kind, ranges=[f"{host}:{port}"])
db.add(run)
await db.commit()
await db.refresh(run)
run_id = run.id
await background(run_id, payload)
async def _run_zigbee_sync() -> None:
await _run_mesh_sync("zigbee")
async def _run_zwave_sync() -> None:
await _run_mesh_sync("zwave")
def _add_service_check_job() -> None:
scheduler.add_job(
_run_service_checks,
"interval",
seconds=settings.service_check_interval,
id="service_checks",
max_instances=1,
coalesce=True,
)
def _add_proxmox_sync_job() -> None:
scheduler.add_job(
_run_proxmox_sync,
"interval",
seconds=settings.proxmox_sync_interval,
id="proxmox_sync",
max_instances=1,
coalesce=True,
)
def _add_zigbee_sync_job() -> None:
scheduler.add_job(
_run_zigbee_sync,
"interval",
seconds=settings.zigbee_sync_interval,
id="zigbee_sync",
max_instances=1,
coalesce=True,
)
def _add_zwave_sync_job() -> None:
scheduler.add_job(
_run_zwave_sync,
"interval",
seconds=settings.zwave_sync_interval,
id="zwave_sync",
max_instances=1,
coalesce=True,
)
logger.error("Status check failed for node %s: %s", node.id, exc)
def start_scheduler() -> None:
global scheduler
if scheduler.running:
try:
scheduler.shutdown(wait=False)
except Exception as exc:
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
scheduler = AsyncIOScheduler()
scheduler.add_job(
_run_status_checks,
"interval",
seconds=settings.status_checker_interval,
id="status_checks",
max_instances=1,
coalesce=True,
)
if settings.service_check_enabled:
_add_service_check_job()
if settings.proxmox_sync_enabled:
_add_proxmox_sync_job()
if settings.zigbee_sync_enabled:
_add_zigbee_sync_job()
if settings.zwave_sync_enabled:
_add_zwave_sync_job()
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def reschedule_status_checks(interval_seconds: int) -> None:
"""Update the status check interval on the running scheduler."""
if interval_seconds < 10:
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
scheduler.reschedule_job("status_checks", trigger="interval", seconds=interval_seconds)
logger.info("Status checks rescheduled to every %ds", interval_seconds)
def reschedule_service_checks(interval_seconds: int) -> None:
"""Update the service-check interval on the running scheduler (if enabled)."""
if interval_seconds < 30:
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("service_checks"):
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
logger.info("Service checks rescheduled to every %ds", interval_seconds)
def set_service_checks_enabled(enabled: bool) -> None:
"""Add or remove the service-check job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("service_checks")
if enabled and not job:
_add_service_check_job()
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
elif not enabled and job:
scheduler.remove_job("service_checks")
logger.info("Service checks disabled")
def reschedule_proxmox_sync(interval_seconds: int) -> None:
"""Update the Proxmox auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("proxmox_sync"):
scheduler.reschedule_job("proxmox_sync", trigger="interval", seconds=interval_seconds)
logger.info("Proxmox auto-sync rescheduled to every %ds", interval_seconds)
def set_proxmox_sync_enabled(enabled: bool) -> None:
"""Add or remove the Proxmox auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("proxmox_sync")
if enabled and not job:
_add_proxmox_sync_job()
logger.info("Proxmox auto-sync enabled — every %ds", settings.proxmox_sync_interval)
elif not enabled and job:
scheduler.remove_job("proxmox_sync")
logger.info("Proxmox auto-sync disabled")
def reschedule_zigbee_sync(interval_seconds: int) -> None:
"""Update the Zigbee auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("zigbee_sync"):
scheduler.reschedule_job("zigbee_sync", trigger="interval", seconds=interval_seconds)
logger.info("Zigbee auto-sync rescheduled to every %ds", interval_seconds)
def set_zigbee_sync_enabled(enabled: bool) -> None:
"""Add or remove the Zigbee auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("zigbee_sync")
if enabled and not job:
_add_zigbee_sync_job()
logger.info("Zigbee auto-sync enabled — every %ds", settings.zigbee_sync_interval)
elif not enabled and job:
scheduler.remove_job("zigbee_sync")
logger.info("Zigbee auto-sync disabled")
def reschedule_zwave_sync(interval_seconds: int) -> None:
"""Update the Z-Wave auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("zwave_sync"):
scheduler.reschedule_job("zwave_sync", trigger="interval", seconds=interval_seconds)
logger.info("Z-Wave auto-sync rescheduled to every %ds", interval_seconds)
def set_zwave_sync_enabled(enabled: bool) -> None:
"""Add or remove the Z-Wave auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("zwave_sync")
if enabled and not job:
_add_zwave_sync_job()
logger.info("Z-Wave auto-sync enabled — every %ds", settings.zwave_sync_interval)
elif not enabled and job:
scheduler.remove_job("zwave_sync")
logger.info("Z-Wave auto-sync disabled")
def stop_scheduler() -> None:
if scheduler.running:
scheduler.shutdown(wait=False)
+5 -8
View File
@@ -1,22 +1,19 @@
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
if not plain or not hashed:
return False
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
return bool(pwd_context.verify(plain, hashed))
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
return str(pwd_context.hash(password))
def create_access_token(subject: str) -> str:
-221
View File
@@ -1,221 +0,0 @@
[
{
"vendor": "Proxmox / QEMU / KVM",
"type": "vm",
"prefixes": ["52:54:00", "bc:24:11"]
},
{
"vendor": "VMware",
"type": "vm",
"prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"]
},
{
"vendor": "VirtualBox",
"type": "vm",
"prefixes": ["08:00:27"]
},
{
"vendor": "Microsoft Hyper-V",
"type": "vm",
"prefixes": ["00:15:5d"]
},
{
"vendor": "Xen",
"type": "vm",
"prefixes": ["00:16:3e"]
},
{
"vendor": "MikroTik",
"type": "router",
"prefixes": [
"00:0c:42",
"08:55:31",
"18:fd:74",
"2c:c8:1b",
"48:8f:5a",
"4c:5e:0c",
"64:d1:54",
"6c:3b:6b",
"74:4d:28",
"b8:69:f4",
"c4:ad:34",
"cc:2d:e0",
"d4:ca:6d",
"dc:2c:6e",
"e4:8d:8c"
]
},
{
"vendor": "Ubiquiti",
"type": "ap",
"prefixes": [
"00:15:6d",
"00:27:22",
"04:18:d6",
"24:5a:4c",
"24:a4:3c",
"44:d9:e7",
"68:72:51",
"68:d7:9a",
"74:83:c2",
"78:8a:20",
"78:45:58",
"80:2a:a8",
"94:2a:6f",
"9c:05:d6",
"b4:fb:e4",
"dc:9f:db",
"e0:63:da",
"f0:9f:c2",
"fc:ec:da"
]
},
{
"vendor": "Ruckus Wireless",
"type": "ap",
"prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"]
},
{
"vendor": "Aruba Networks (HPE)",
"type": "ap",
"prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"]
},
{
"vendor": "Cisco Systems",
"type": "switch",
"prefixes": [
"00:00:0c",
"00:1b:0d",
"00:1c:f6",
"00:1e:13",
"00:23:04",
"00:24:13",
"00:25:45",
"00:50:0b",
"b0:00:b4",
"b8:38:61",
"f8:c0:01"
]
},
{
"vendor": "Juniper Networks",
"type": "switch",
"prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"]
},
{
"vendor": "Zyxel",
"type": "switch",
"prefixes": ["00:13:49", "60:31:97", "ec:43:f6"]
},
{
"vendor": "Netgear",
"type": "router",
"prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"]
},
{
"vendor": "TP-Link",
"type": "router",
"prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"]
},
{
"vendor": "Synology",
"type": "nas",
"prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"]
},
{
"vendor": "QNAP Systems",
"type": "nas",
"prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"]
},
{
"vendor": "Asustor",
"type": "nas",
"prefixes": ["e8:9c:25"]
},
{
"vendor": "Hikvision",
"type": "camera",
"prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"]
},
{
"vendor": "Dahua / Amcrest",
"type": "camera",
"prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"]
},
{
"vendor": "Reolink",
"type": "camera",
"prefixes": ["ec:71:db"]
},
{
"vendor": "Axis Communications",
"type": "camera",
"prefixes": ["00:40:8c", "ac:cc:8e"]
},
{
"vendor": "Raspberry Pi Foundation",
"type": "server",
"prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"]
},
{
"vendor": "Dell",
"type": "server",
"prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"]
},
{
"vendor": "Supermicro",
"type": "server",
"prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"]
},
{
"vendor": "Shelly",
"type": "iot",
"prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"]
},
{
"vendor": "Espressif (ESP8266 / ESP32)",
"type": "iot",
"prefixes": [
"24:62:ab",
"30:ae:a4",
"3c:71:bf",
"8c:aa:b5",
"a0:20:a6",
"ac:67:b2",
"b4:e6:2d",
"cc:50:e3"
]
},
{
"vendor": "Sonoff / ITEAD",
"type": "iot",
"prefixes": ["dc:4f:22", "e8:db:84"]
},
{
"vendor": "TP-Link Tapo / Kasa",
"type": "iot",
"prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"]
},
{
"vendor": "Philips Hue",
"type": "iot",
"prefixes": ["00:17:88", "ec:b5:fa"]
},
{
"vendor": "IKEA Tradfri",
"type": "iot",
"prefixes": ["00:21:2e", "34:13:e8"]
},
{
"vendor": "Tuya / Smart Life",
"type": "iot",
"prefixes": ["68:57:2d", "d8:f1:5b"]
}
]
+1 -65
View File
@@ -142,69 +142,5 @@
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
]
+17 -313
View File
@@ -1,35 +1,11 @@
import json as _json
import logging
import shutil
import uuid as _uuid_mod
from collections.abc import AsyncGenerator
from contextlib import suppress
from pathlib import Path
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import APP_VERSION, settings
logger = logging.getLogger(__name__)
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
"""Run an idempotent migration statement, logging any error.
Distinguishes 'already applied' errors (debug) from genuine failures
(warning) so silent corruption is avoided. Used for new in-commit
migrations; existing legacy ALTERs above remain wrapped in suppress.
"""
try:
await conn.exec_driver_sql(sql)
except OperationalError as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
logger.debug("Migration %s skipped (already applied): %s", label, exc)
else:
logger.warning("Migration %s failed: %s", label, exc)
from app.core.config import settings
# Ensure the data directory exists before SQLite tries to open the file
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
@@ -46,312 +22,40 @@ class Base(DeclarativeBase):
pass
def _backup_db() -> None:
db_path = Path(settings.sqlite_path)
if not db_path.exists():
return
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
if backup_path.exists():
return
try:
shutil.copy2(db_path, backup_path)
logger.info("DB backup created: %s", backup_path.name)
except OSError:
logger.warning("Could not create DB backup at %s", backup_path)
async def init_db() -> None:
_backup_db()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent)
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start TEXT NOT NULL DEFAULT 'none'")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end TEXT NOT NULL DEFAULT 'none'")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN line_style TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN width_mult REAL")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError):
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN top_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN left_handles INTEGER NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
zigbee_migrations: list[tuple[str, str]] = [
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
(
"nodes.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
),
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
(
"pending_devices.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)",
),
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
]
for label, sql in zigbee_migrations:
await _try_migrate(conn, sql, label=label)
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
# SQLite can't ALTER column nullability — rebuild the table if needed.
try:
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
cols = info.fetchall()
ip_col = next((c for c in cols if c[1] == "ip"), None)
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
if ip_col and ip_col[3] == 1:
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE pending_devices_new ("
"id VARCHAR PRIMARY KEY,"
"ip VARCHAR,"
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
"suggested_type VARCHAR,"
"status VARCHAR,"
"discovery_source VARCHAR,"
"ieee_address VARCHAR,"
"friendly_name VARCHAR,"
"device_subtype VARCHAR,"
"model VARCHAR,"
"vendor VARCHAR,"
"lqi INTEGER,"
"properties JSON,"
"discovered_at DATETIME"
")"
)
await conn.exec_driver_sql(
"INSERT INTO pending_devices_new "
"(id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at) "
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at FROM pending_devices"
)
await conn.exec_driver_sql("DROP TABLE pending_devices")
await conn.exec_driver_sql(
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
)
await conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)"
)
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations -------------------------------------
# --- Electrical designs schema migrations -----------------------------
# Create designs table (idempotent)
await _try_migrate(
conn,
"CREATE TABLE IF NOT EXISTS designs ("
"id VARCHAR PRIMARY KEY,"
"name VARCHAR NOT NULL,"
"design_type VARCHAR NOT NULL DEFAULT 'network',"
"created_at DATETIME,"
"updated_at DATETIME"
")",
label="designs.table",
)
# Add user-chosen icon to designs (idempotent), then backfill existing rows
# so legacy designs keep a sensible icon based on their original type.
await _try_migrate(
conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon",
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL"
)
# Seed default Network Topology design if designs table is empty
_default_design_id = str(_uuid_mod.uuid4())
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
count_row = row.fetchone()
count = count_row[0] if count_row else 0
if count == 0:
await conn.exec_driver_sql(
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
"VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))",
(_default_design_id,),
)
else:
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
default = row2.fetchone()
_default_design_id = default[0] if default else _default_design_id
# Add design_id to nodes
await _try_migrate(
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="nodes.design_id",
)
# Assign existing nodes to default design
await conn.exec_driver_sql(
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Add design_id to edges
await _try_migrate(
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="edges.design_id",
)
# Assign existing edges to default design
await conn.exec_driver_sql(
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
try:
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
cols = info.fetchall()
has_design_id = any(c[1] == "design_id" for c in cols)
if not has_design_id:
logger.info("Migrating canvas_state: switching to design_id primary key")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE canvas_state_new ("
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
"viewport JSON,"
"custom_style JSON,"
"saved_at DATETIME"
")"
)
# Copy existing row(s), mapping id=1 to default design_id
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
for old in old_rows.fetchall():
cs_id, viewport, custom_style, saved_at = old
target_design = _default_design_id
await conn.exec_driver_sql(
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
"VALUES (?, ?, ?, ?)",
(target_design, viewport, custom_style, saved_at),
)
await conn.exec_driver_sql("DROP TABLE canvas_state")
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("canvas_state migration failed: %s", exc)
# --- end Electrical designs schema migrations --------------------------
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError):
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for r in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
props = []
visible = bool(show_hardware)
if cpu_model:
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
if cpu_count is not None:
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
if ram_gb is not None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id),
)
# Inventory timestamp: last time a scan observed this node (idempotent)
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN last_scan DATETIME")
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
# Multi-source discovery tags: a device found by both an IP scan and a
# Proxmox import carries every source. Backfill from the legacy single
# discovery_source so existing rows show under their filter.
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_sources JSON")
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
)
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
# but a real IP — treat them as an ARP scan so they keep the IP tag.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array('arp') "
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
)
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+5 -81
View File
@@ -16,24 +16,12 @@ def _uuid() -> str:
return str(uuid.uuid4())
class Design(Base):
__tablename__ = "designs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String, nullable=False)
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class Node(Base):
__tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False)
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String)
@@ -45,7 +33,7 @@ class Node(Base):
notes: Mapped[str | None] = mapped_column(Text)
pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -54,20 +42,13 @@ class Node(Base):
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
top_handles: Mapped[int] = mapped_column(Integer, default=1)
left_handles: Mapped[int] = mapped_column(Integer, default=0)
right_handles: Mapped[int] = mapped_column(Integer, default=0)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
@@ -78,93 +59,37 @@ class Edge(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
type: Mapped[str] = mapped_column(String, default="ethernet")
label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer)
speed: Mapped[str | None] = mapped_column(String)
custom_color: Mapped[str | None] = mapped_column(String)
path_style: Mapped[str | None] = mapped_column(String)
line_style: Mapped[str | None] = mapped_column(String)
width_mult: Mapped[float | None] = mapped_column(Float)
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none')
marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none')
animated: Mapped[bool] = mapped_column(Boolean, default=False)
source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class CanvasState(Base):
__tablename__ = "canvas_state"
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingDevice(Base):
__tablename__ = "pending_devices"
# Permit the plain (non-Mapped[]) annotations on the transient request-only
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
__allow_unmapped__ = True
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
ip: Mapped[str | None] = mapped_column(String, nullable=True)
ip: Mapped[str] = mapped_column(String, nullable=False)
mac: Mapped[str | None] = mapped_column(String)
hostname: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String)
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending")
# Origin/primary source (first discovery): "arp"/"mdns"/"zigbee"/"zwave"/
# "proxmox". Kept for back-compat; `discovery_sources` is the full set.
discovery_source: Mapped[str | None] = mapped_column(String)
# All sources that have observed this device. A device found by both an IP
# scan and a Proxmox import carries e.g. ["arp", "proxmox"] and shows under
# both inventory filters. Source of truth for the frontend source badges.
discovery_sources: Mapped[list[Any]] = mapped_column(JSON, default=list)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
model: Mapped[str | None] = mapped_column(String, nullable=True)
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Display properties carried from discovery (e.g. Proxmox specs: CPU/RAM/Disk,
# VMID). Generic NodeProperty shape {key,value,icon,visible}; merged into the
# Node's properties on approve. Empty for scan/mesh sources that don't set it.
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# Transient (not persisted): populated per-request by the scan routes to report
# how many canvases this device already appears on. Not a mapped column.
canvas_count: int = 0
# Transient (not persisted): timestamps from the linked canvas node(s),
# correlated by ip / ieee_address. None when the device is not on any canvas.
node_created_at: datetime | None = None
node_last_scan: datetime | None = None
node_last_modified: datetime | None = None
node_last_seen: datetime | None = None
class PendingDeviceLink(Base):
"""Link between two Zigbee endpoints discovered during import.
Endpoints are addressed by IEEE (stable across re-imports). Either side may
already exist as a canvas Node (resolved via Node.ieee_address) or still be
a PendingDevice. On approval, the matching Edge is auto-created when both
endpoints exist as canvas Nodes.
"""
__tablename__ = "pending_device_links"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -173,7 +98,6 @@ class ScanRun(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
status: Mapped[str] = mapped_column(String, default="running")
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
devices_found: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -37
View File
@@ -1,5 +1,3 @@
import logging
import logging.config
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
@@ -7,22 +5,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import (
auth,
canvas,
designs,
edges,
liveview,
media,
nodes,
proxmox,
scan,
stats,
status,
zigbee,
zwave,
)
from app.api.routes import settings as settings_routes
from app.api.routes import auth, canvas, edges, nodes, scan, status
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
from app.db.database import init_db
@@ -30,16 +13,6 @@ from app.db.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Ensure app logs are visible: attach a handler to the root logger if none
# exists (uvicorn only installs handlers on its own loggers, not the root).
root_logger = logging.getLogger()
if not root_logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)
logging.getLogger("app").setLevel(logging.INFO)
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
await init_db()
settings.load_overrides()
start_scheduler()
@@ -49,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(
title="Homelable API",
version="1.9.0",
version="1.3.3",
lifespan=lifespan,
)
@@ -65,16 +38,8 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
app.include_router(proxmox.router, prefix="/api/v1/proxmox", tags=["proxmox"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
@app.get("/api/v1/health")
+2 -32
View File
@@ -1,10 +1,9 @@
from typing import Any
from pydantic import BaseModel, field_validator
from pydantic import BaseModel
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated, normalize_marker
class NodeSave(BaseModel):
@@ -29,14 +28,8 @@ class NodeSave(BaseModel):
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
show_port_numbers: bool = False
properties: list[Any] = []
width: float | None = None
height: float | None = None
bottom_handles: int = 1
top_handles: int = 1
left_handles: int = 0
right_handles: int = 0
pos_x: float = 0
pos_y: float = 0
@@ -51,41 +44,18 @@ class EdgeSave(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
line_style: str | None = None
width_mult: float | None = None
animated: str = 'none'
marker_start: str = 'none'
marker_end: str = 'none'
animated: bool = False
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str:
return normalize_marker(v)
class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = []
edges: list[EdgeSave] = []
viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None
design_id: str | None = None
class CanvasStateResponse(BaseModel):
nodes: list[NodeResponse]
edges: list[EdgeResponse]
viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
# True once this design's canvas has ever been persisted (a CanvasState row
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
# who intentionally cleared their canvas (keep it empty). False also for a
# missing/uninitialized design.
initialized: bool = False
-39
View File
@@ -1,39 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class DesignCreate(BaseModel):
name: str
icon: str = "dashboard"
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
# the chosen icon now drives presentation. Defaults to a generic canvas.
design_type: str = "network"
class DesignUpdate(BaseModel):
name: str | None = None
icon: str | None = None
class DesignCopy(BaseModel):
"""Create a new design by deep-copying an existing one's canvas."""
name: str
icon: str = "dashboard"
class DesignResponse(BaseModel):
id: str
name: str
design_type: str
icon: str | None = None
created_at: datetime
updated_at: datetime
# Populated by list_designs so the "copy from existing" picker can show what
# each canvas holds. None on create/update/copy responses (not computed there).
node_count: int | None = None
group_count: int | None = None
text_count: int | None = None
model_config = {"from_attributes": True}
+4 -41
View File
@@ -1,8 +1,6 @@
from datetime import datetime
from pydantic import BaseModel, field_validator
from app.schemas.utils import normalize_animated, normalize_marker
from pydantic import BaseModel
class EdgeBase(BaseModel):
@@ -14,28 +12,13 @@ class EdgeBase(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
line_style: str | None = None
width_mult: float | None = None
animated: str = 'none'
marker_start: str = 'none'
marker_end: str = 'none'
animated: bool = False
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str:
return normalize_marker(v)
class EdgeCreate(EdgeBase):
design_id: str | None = None
pass
class EdgeUpdate(BaseModel):
@@ -45,33 +28,13 @@ class EdgeUpdate(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
line_style: str | None = None
width_mult: float | None = None
animated: str | None = None
marker_start: str | None = None
marker_end: str | None = None
animated: bool | None = None
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before')
@classmethod
def validate_animated(cls, v: object) -> str | None:
if v is None:
return None
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str | None:
if v is None:
return None
return normalize_marker(v)
class EdgeResponse(EdgeBase):
id: str
design_id: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
+1 -24
View File
@@ -27,26 +27,12 @@ class NodeBase(BaseModel):
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
show_port_numbers: bool = False
properties: list[dict[str, Any]] = []
width: float | None = None
height: float | None = None
bottom_handles: int = 1
top_handles: int = 1
left_handles: int = 0
right_handles: int = 0
class NodeCreate(NodeBase):
# Override pos_x/pos_y so callers can omit them; None signals "auto-place".
# The create_node route resolves None to a free grid slot before persisting.
pos_x: float | None = None # type: ignore[assignment]
pos_y: float | None = None # type: ignore[assignment]
design_id: str | None = None
# When a node with the same ip/mac already exists on the target design, the
# create/approve endpoints reject with 409 so the UI can ask the user. Set
# force=True to bypass that guard and create the duplicate deliberately.
force: bool = False
pass
class NodeUpdate(BaseModel):
@@ -72,22 +58,13 @@ class NodeUpdate(BaseModel):
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool | None = None
show_port_numbers: bool | None = None
properties: list[dict[str, Any]] | None = None
width: float | None = None
height: float | None = None
bottom_handles: int | None = None
top_handles: int | None = None
left_handles: int | None = None
right_handles: int | None = None
class NodeResponse(NodeBase):
id: str
design_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None
last_scan: datetime | None = None
response_time_ms: int | None = None
created_at: datetime
updated_at: datetime
-87
View File
@@ -1,87 +0,0 @@
"""Pydantic v2 schemas for Proxmox VE import.
Token fields are accepted on requests only and are optional — when omitted the
backend falls back to the server-configured token (env). No response schema ever
carries a token; secrets are kept out of responses by structural omission.
"""
from pydantic import BaseModel, Field
class ProxmoxConnectionRequest(BaseModel):
host: str = Field(..., description="Proxmox VE host or IP")
port: int = Field(8006, ge=1, le=65535, description="Proxmox API port")
token_id: str | None = Field(
None, description="API token id 'user@realm!tokenname' (falls back to server env)"
)
token_secret: str | None = Field(
None, description="API token secret (falls back to server env)"
)
verify_tls: bool = Field(True, description="Verify the Proxmox TLS certificate")
class ProxmoxTestConnectionResponse(BaseModel):
connected: bool
message: str
class ProxmoxNodeOut(BaseModel):
"""A homelable-ready node representation of a Proxmox host / VM / LXC."""
id: str
label: str
type: str # proxmox | vm | lxc
ieee_address: str
hostname: str | None = None
ip: str | None = None
status: str
cpu_count: int | None = None
ram_gb: float | None = None
disk_gb: float | None = None
vendor: str | None = None
model: str | None = None
parent_ieee: str | None = None
class ProxmoxEdgeOut(BaseModel):
source: str
target: str
class ProxmoxImportResponse(BaseModel):
nodes: list[ProxmoxNodeOut]
edges: list[ProxmoxEdgeOut]
device_count: int
class ProxmoxImportPendingResponse(BaseModel):
"""Result of importing a Proxmox inventory into the pending section."""
pending_created: int
pending_updated: int
links_recorded: int
device_count: int
class ProxmoxConfig(BaseModel):
"""Non-secret Proxmox connection + auto-sync config (GET response).
Connection fields (host/port/verify_tls) are env-only and read-only here —
surfaced for display. ``token_configured`` reflects whether a server-side
token is present. Never carries the token itself."""
host: str = ""
port: int = Field(8006, ge=1, le=65535)
verify_tls: bool = True
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
token_configured: bool = False
class ProxmoxSyncConfig(BaseModel):
"""User-editable auto-sync config (POST body). The ONLY persisted Proxmox
settings. Connection fields (host/port/token/verify_tls) are env-only and
are deliberately not accepted here."""
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
+2 -32
View File
@@ -1,48 +1,19 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, field_validator
from pydantic import BaseModel
class PendingDeviceResponse(BaseModel):
id: str
ip: str | None
ip: str
mac: str | None
hostname: str | None
os: str | None
services: list[Any]
suggested_type: str | None
status: str
discovery_source: str | None
# All sources that have observed this device (e.g. ["arp", "proxmox"]). Drives
# the inventory source filter + badges; falls back to [discovery_source].
discovery_sources: list[str] = []
ieee_address: str | None = None
friendly_name: str | None = None
device_subtype: str | None = None
model: str | None = None
vendor: str | None = None
lqi: int | None = None
# Display properties carried from discovery (e.g. Proxmox specs). Merged into
# the node on approve; empty for scan/mesh sources that don't set them.
properties: list[Any] = []
discovered_at: datetime
# Number of distinct canvases (designs) this device already appears on,
# correlated by ip / ieee_address against existing nodes. Computed per-request.
canvas_count: int = 0
# Timestamps from the linked canvas node(s), correlated by ip / ieee_address.
# Null when the device is not on any canvas yet. Aggregated across matches:
# created_at = oldest; last_scan / last_modified / last_seen = newest.
node_created_at: datetime | None = None
node_last_scan: datetime | None = None
node_last_modified: datetime | None = None
node_last_seen: datetime | None = None
@field_validator("properties", "discovery_sources", mode="before")
@classmethod
def _coerce_list(cls, v: Any) -> list[Any]:
# Legacy rows (columns added by migration) have these = NULL.
return v if isinstance(v, list) else []
model_config = {"from_attributes": True}
@@ -50,7 +21,6 @@ class PendingDeviceResponse(BaseModel):
class ScanRunResponse(BaseModel):
id: str
status: str
kind: str = "ip"
ranges: list[str]
devices_found: int
started_at: datetime
-27
View File
@@ -1,27 +0,0 @@
def normalize_animated(v: object) -> str:
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
if v is True or v == 1 or v == '1':
return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none'
if v in ('snake', 'flow', 'basic'):
return str(v)
return 'none'
MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'}
def normalize_marker(v: object) -> str:
"""Normalize an edge endpoint marker to a shape string.
Legacy saves stored a boolean (True = filled arrow); coerce those and any
unknown value to a valid MarkerShape ('none' when off/unknown).
"""
if v is True or v == 1 or v == '1':
return 'arrow'
if v is False or v == 0 or v == '0' or v is None:
return 'none'
if isinstance(v, str) and v in MARKER_SHAPES:
return v
return 'none'
-121
View File
@@ -1,121 +0,0 @@
"""Pydantic v2 schemas for Zigbee2MQTT import."""
from pydantic import BaseModel, Field, model_validator
class ZigbeeImportRequest(BaseModel):
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
mqtt_tls_insecure: bool = Field(
False, description="Skip TLS certificate verification (self-signed only)"
)
@model_validator(mode="after")
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
if self.mqtt_tls_insecure and not self.mqtt_tls:
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
return self
class ZigbeeTestConnectionRequest(BaseModel):
mqtt_host: str
mqtt_port: int = Field(1883, ge=1, le=65535)
mqtt_username: str | None = None
mqtt_password: str | None = None
mqtt_tls: bool = False
mqtt_tls_insecure: bool = False
@model_validator(mode="after")
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
if self.mqtt_tls_insecure and not self.mqtt_tls:
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
return self
class ZigbeeDeviceData(BaseModel):
ieee_address: str
friendly_name: str
device_type: str # Coordinator, Router, EndDevice
model: str | None = None
vendor: str | None = None
description: str | None = None
lqi: int | None = None
last_seen: str | None = None
class ZigbeeNodeOut(BaseModel):
"""A homelable-ready node representation of a Zigbee device."""
id: str
label: str
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
ieee_address: str
friendly_name: str
device_type: str
model: str | None = None
vendor: str | None = None
lqi: int | None = None
parent_id: str | None = None
class ZigbeeEdgeOut(BaseModel):
source: str
target: str
class ZigbeeImportResponse(BaseModel):
nodes: list[ZigbeeNodeOut]
edges: list[ZigbeeEdgeOut]
device_count: int
class ZigbeeTestConnectionResponse(BaseModel):
connected: bool
message: str
class ZigbeeCoordinatorOut(BaseModel):
id: str
label: str
ieee_address: str
class ZigbeeImportPendingResponse(BaseModel):
"""Result of importing a Z2M network into the pending section."""
pending_created: int
pending_updated: int
coordinator: ZigbeeCoordinatorOut | None = None
coordinator_already_existed: bool = False
links_recorded: int
device_count: int
class ZigbeeConfig(BaseModel):
"""Non-secret Zigbee connection + auto-sync config (GET response).
MQTT connection fields (host/port/base_topic/tls) are env-only and
read-only here — surfaced for display. ``host_configured`` reflects whether
a server-side MQTT host is set (required for auto-sync). MQTT credentials
(username/password) are never carried."""
mqtt_host: str = ""
mqtt_port: int = Field(1883, ge=1, le=65535)
base_topic: str = "zigbee2mqtt"
mqtt_tls: bool = False
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
host_configured: bool = False
class ZigbeeSyncConfig(BaseModel):
"""User-editable auto-sync config (POST body). The ONLY persisted Zigbee
settings. Connection fields (host/port/credentials/topic/tls) are env-only
and are deliberately not accepted here."""
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
-112
View File
@@ -1,112 +0,0 @@
"""Pydantic v2 schemas for Z-Wave JS UI (zwavejs2mqtt) import."""
from pydantic import BaseModel, Field, model_validator
class ZwaveImportRequest(BaseModel):
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
prefix: str = Field("zwave", description="Z-Wave JS UI MQTT prefix")
gateway_name: str = Field("zwavejs2mqtt", description="Z-Wave JS UI gateway name")
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
mqtt_tls_insecure: bool = Field(
False, description="Skip TLS certificate verification (self-signed only)"
)
@model_validator(mode="after")
def _insecure_requires_tls(self) -> "ZwaveImportRequest":
if self.mqtt_tls_insecure and not self.mqtt_tls:
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
return self
class ZwaveTestConnectionRequest(BaseModel):
mqtt_host: str
mqtt_port: int = Field(1883, ge=1, le=65535)
mqtt_username: str | None = None
mqtt_password: str | None = None
mqtt_tls: bool = False
mqtt_tls_insecure: bool = False
@model_validator(mode="after")
def _insecure_requires_tls(self) -> "ZwaveTestConnectionRequest":
if self.mqtt_tls_insecure and not self.mqtt_tls:
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
return self
class ZwaveNodeOut(BaseModel):
"""A homelable-ready node representation of a Z-Wave device."""
id: str
label: str
type: str # zwave_coordinator | zwave_router | zwave_enddevice
ieee_address: str
friendly_name: str
device_type: str
model: str | None = None
vendor: str | None = None
lqi: int | None = None
parent_id: str | None = None
class ZwaveEdgeOut(BaseModel):
source: str
target: str
class ZwaveImportResponse(BaseModel):
nodes: list[ZwaveNodeOut]
edges: list[ZwaveEdgeOut]
device_count: int
class ZwaveTestConnectionResponse(BaseModel):
connected: bool
message: str
class ZwaveCoordinatorOut(BaseModel):
id: str
label: str
ieee_address: str
class ZwaveImportPendingResponse(BaseModel):
"""Result of importing a Z-Wave network into the pending section."""
pending_created: int
pending_updated: int
coordinator: ZwaveCoordinatorOut | None = None
coordinator_already_existed: bool = False
links_recorded: int
device_count: int
class ZwaveConfig(BaseModel):
"""Non-secret Z-Wave connection + auto-sync config (GET response).
MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and
read-only here — surfaced for display. ``host_configured`` reflects whether
a server-side MQTT host is set (required for auto-sync). MQTT credentials
(username/password) are never carried."""
mqtt_host: str = ""
mqtt_port: int = Field(1883, ge=1, le=65535)
prefix: str = "zwave"
gateway_name: str = "zwavejs2mqtt"
mqtt_tls: bool = False
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
host_configured: bool = False
class ZwaveSyncConfig(BaseModel):
"""User-editable auto-sync config (POST body). The ONLY persisted Z-Wave
settings. Connection fields (host/port/credentials/prefix/gateway/tls) are
env-only and are deliberately not accepted here."""
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
-19
View File
@@ -1,19 +0,0 @@
"""Helpers for the multi-valued ``PendingDevice.discovery_sources`` set.
A device discovered by more than one path (e.g. an IP scan *and* a Proxmox
import) accumulates every source that has seen it, so it surfaces under each
matching inventory filter. Order is preserved (origin first) and duplicates are
dropped.
"""
from __future__ import annotations
from collections.abc import Iterable
def add_source(sources: Iterable[str] | None, source: str | None) -> list[str]:
"""Return ``sources`` with ``source`` appended if not already present."""
out = [s for s in (sources or []) if s]
if source and source not in out:
out.append(source)
return out
+30 -127
View File
@@ -6,7 +6,6 @@ from pathlib import Path
from typing import Any
_SIGNATURES: list[dict[str, Any]] | None = None
_OUI_MAP: dict[str, str] | None = None
_LOCK = threading.Lock()
@@ -27,124 +26,25 @@ def _load() -> list[dict[str, Any]]:
return _SIGNATURES
def _load_oui() -> dict[str, str]:
"""Load OUI database and flatten to {prefix: node_type}."""
global _OUI_MAP
if _OUI_MAP is None:
with _LOCK:
if _OUI_MAP is None:
path = Path(__file__).parent.parent / "data" / "oui_database.json"
try:
with open(path) as f:
entries = json.load(f)
except FileNotFoundError as err:
raise FileNotFoundError(
f"oui_database.json not found at {path}. "
"This file should be bundled with the application."
) from err
_OUI_MAP = {
prefix.lower(): entry["type"]
for entry in entries
for prefix in entry["prefixes"]
}
return _OUI_MAP
def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool:
"""True when the signature's http_regex matches the probe's title/headers."""
rx = sig.get("http_regex")
if not rx or not http_signals:
return False
headers = http_signals.get("headers") or {}
haystack = " ".join(
s for s in (
http_signals.get("title"),
headers.get("Server"),
headers.get("X-Powered-By"),
) if s
)
return bool(haystack and re.search(rx, haystack, re.IGNORECASE))
def _service_tier(
sig: dict[str, Any],
port: int,
protocol: str,
banner: str | None,
http_signals: dict[str, Any] | None,
) -> int | None:
"""
Rank how well a signature matches (lower = stronger). None = not a match.
Tier 1: port match + http_regex confirmed
Tier 2: port match + banner_regex confirmed
Tier 3: port-agnostic (port: null) + http_regex confirmed
Tier 4: port match only (no regex, or http_regex with probe disabled)
When http_signals is None (probe not run) an http_regex entry degrades to
a port-only match — identical to pre-probe behaviour, no regression.
When http_signals is provided, http_regex is strict: a miss disqualifies.
"""
probe_ran = http_signals is not None
has_http = bool(sig.get("http_regex"))
# Port-agnostic entries (port: null) match purely on HTTP signals.
if sig.get("port") is None:
if has_http and _http_regex_hit(sig, http_signals):
return 3
return None
if sig["port"] != port or sig["protocol"] != protocol:
return None
# http_regex is authoritative once a probe has run.
if has_http and probe_ran:
return 1 if _http_regex_hit(sig, http_signals) else None
if sig.get("banner_regex"):
if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE):
return 2
return None
# No regex constraint (or http_regex but probe disabled) → port-only guess.
return 4
def match_service(
port: int,
protocol: str,
banner: str | None = None,
http_signals: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Return the best signature for a port, walking tiers most-specific first."""
best: dict[str, Any] | None = None
best_tier = 99
for sig in _load():
tier = _service_tier(sig, port, protocol, banner, http_signals)
if tier is not None and tier < best_tier:
best, best_tier = sig, tier
if best_tier == 1:
break # strongest possible — stop early
return best
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
"""Back-compat alias: match without HTTP-probe signals."""
return match_service(port, protocol, banner)
"""Return the first signature matching port+protocol, optionally banner."""
for sig in _load():
if sig["port"] != port or sig["protocol"] != protocol:
continue
if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)):
continue
return sig
return None
def fingerprint_ports(
open_ports: list[dict[str, Any]],
) -> list[dict[str, Any]]:
def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Given a list of {port, protocol, banner?, http_signals?} dicts, return
matched services. Unknown ports are included as unknown_service.
Given a list of {port, protocol, banner?} dicts, return matched services.
Unknown ports are included as unknown_service.
"""
results = []
for p in open_ports:
sig = match_service(
p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals")
)
sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
if sig:
results.append({
"port": p["port"],
@@ -165,12 +65,23 @@ def fingerprint_ports(
return results
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated)
_MAC_OUI_TYPES: dict[str, str] = {
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
"00:50:56": "vm", # VMware
"00:0c:29": "vm", # VMware Workstation / Fusion
"08:00:27": "vm", # VirtualBox
"00:15:5d": "vm", # Hyper-V
}
def suggest_type_from_mac(mac: str | None) -> str | None:
"""Return a suggested node type from MAC OUI, or None if unknown."""
if not mac:
return None
prefix = mac.lower()[:8]
return _load_oui().get(prefix)
return _MAC_OUI_TYPES.get(prefix)
_PORT_TYPE_HINTS: dict[int, str] = {
@@ -190,13 +101,10 @@ _PORT_TYPE_HINTS: dict[int, str] = {
37777: "camera", # Dahua
34567: "camera", # Amcrest
2020: "camera", # Tapo
# Smart-home / MQTT / CoAP → iot
# Smart-home / MQTT → iot
1883: "iot",
8883: "iot",
6052: "iot", # ESPHome dashboard
4915: "iot", # Shelly CoIoT
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
5684: "iot", # CoAP DTLS
6052: "iot", # ESPHome
# AP / wireless
8880: "ap", # UniFi HTTP
8443: "ap", # UniFi HTTPS
@@ -207,13 +115,8 @@ _PORT_TYPE_HINTS: dict[int, str] = {
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
# IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
mac_type = suggest_type_from_mac(mac)
if mac_type == "iot":
return "iot"
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
"""Suggest a node type based on matched signatures and MAC OUI."""
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
found: set[str] = set()
for p in open_ports:
port = p["port"]
@@ -223,10 +126,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
found.add(sig["suggested_node_type"])
if port in _PORT_TYPE_HINTS:
found.add(_PORT_TYPE_HINTS[port])
# MAC OUI is a lower-priority hint — only used if ports give no better answer
mac_type = suggest_type_from_mac(mac)
if mac_type:
found.add(mac_type)
for t in priority:
if t in found:
return t
-85
View File
@@ -1,85 +0,0 @@
"""HTTP probe: GET a discovered port and extract identifying signals.
Used by the optional deep-scan mode to confirm what service sits behind an
open port, regardless of port number. Returns the page <title> plus a small
set of identifying response headers, which fingerprint.match_service() then
matches against signature http_regex fields.
"""
import asyncio
import logging
import re
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Headers that commonly carry the application name.
_SIGNAL_HEADERS = ("Server", "X-Powered-By")
# Cap how much body we read when hunting for <title> — avoids large downloads.
_MAX_BODY_BYTES = 64 * 1024
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
_PROBE_TIMEOUT = 3.0
# Ports we never bother probing over HTTP (not web services).
_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
def _extract_title(body: str) -> str | None:
m = _TITLE_RE.search(body)
if not m:
return None
title = re.sub(r"\s+", " ", m.group(1)).strip()
return title or None
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
try:
resp = await client.get(url, follow_redirects=True)
except (httpx.HTTPError, OSError):
return None
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
title = _extract_title(body)
if not title and not headers:
return None
return {"title": title, "headers": headers}
async def probe_port(
ip: str, port: int, verify_tls: bool = False
) -> dict[str, Any] | None:
"""
GET https:// then http:// for a port and return {title, headers} or None.
None means the port did not answer HTTP or yielded no usable signal.
"""
if port in _NON_HTTP_PORTS:
return None
async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
for scheme in ("https", "http"):
result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
if result is not None:
return result
return None
async def probe_open_ports(
ip: str,
open_ports: list[dict[str, Any]],
verify_tls: bool = False,
concurrency: int = 50,
) -> list[dict[str, Any]]:
"""
Probe every open port for HTTP signals (option 2: probe all, match after).
Returns the same port dicts, each enriched with an http_signals key
(None when the port gave no HTTP signal).
"""
sem = asyncio.Semaphore(concurrency)
async def _one(p: dict[str, Any]) -> dict[str, Any]:
async with sem:
signals = await probe_port(ip, p["port"], verify_tls)
return {**p, "http_signals": signals}
return await asyncio.gather(*(_one(p) for p in open_ports))
-18
View File
@@ -1,18 +0,0 @@
"""MAC-address normalization, shared by the scan + Proxmox persist paths.
Different discovery sources emit MACs in different casing/separators (ARP is
lowercase ``bc:24:11:..``, Proxmox config is often uppercase ``BC:24:11:..``).
Canonicalizing on write *and* on compare lets cross-source dedup match a device
by MAC with a plain ``==`` — the join key for merging an IP-scanned row with a
Proxmox-imported one.
"""
from __future__ import annotations
def normalize_mac(mac: str | None) -> str | None:
"""Canonical MAC: lowercase, ``-`` → ``:``, stripped. Blank/None → None."""
if not mac:
return None
normalized = mac.strip().lower().replace("-", ":")
return normalized or None
-168
View File
@@ -1,168 +0,0 @@
"""Shared MQTT helpers for the Zigbee and Z-Wave import services.
Holds the credential-safe error sanitizer, the TLS context builder, and a
generic request/response round-trip over MQTT used by gateway-style APIs
(publish a request topic, wait for a single response topic message).
"""
from __future__ import annotations
import asyncio
import json
import logging
import ssl
from typing import Any
logger = logging.getLogger(__name__)
try:
import aiomqtt
except ImportError: # pragma: no cover
aiomqtt = None # type: ignore[assignment]
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are slow)
def _sanitize_mqtt_error(exc: BaseException) -> str:
"""Return a generic, credential-free message for an MQTT error.
The raw aiomqtt/paho error string can include the broker URI with
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
detail that should not leak to API clients. Map known patterns to
coarse categories; default to a generic failure message. The original
exception is logged at WARNING level for operator debugging.
"""
logger.warning("MQTT error (sanitized for client): %r", exc)
raw = str(exc).lower()
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
return "Authentication failed"
if "refused" in raw:
return "Connection refused by broker"
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
return "Broker hostname could not be resolved"
if "ssl" in raw or "tls" in raw or "certificate" in raw:
return "TLS handshake failed"
if "timed out" in raw or "timeout" in raw:
return "Connection to broker timed out"
return "MQTT connection failed"
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
ctx = ssl.create_default_context()
if insecure:
logger.warning(
"MQTT TLS certificate verification is DISABLED — "
"use only with self-signed brokers on trusted networks."
)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
async def request_response(
mqtt_host: str,
mqtt_port: int,
request_topic: str,
response_topic: str,
request_payload: dict[str, Any],
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
response_timeout: float = _RESPONSE_TIMEOUT,
) -> dict[str, Any]:
"""Publish ``request_payload`` to ``request_topic`` and return the first
JSON message received on ``response_topic`` as a dict.
Raises:
ImportError: if aiomqtt is not installed.
TimeoutError: if no response arrives in time.
ConnectionError: if the broker cannot be reached.
ValueError: if the response payload is not valid JSON / is empty.
"""
if aiomqtt is None: # pragma: no cover
raise ImportError(
"aiomqtt is required for MQTT import. "
"Install it with: pip install aiomqtt"
)
response_payload: dict[str, Any] = {}
tls_context = _build_tls_context(tls_insecure) if tls else None
try:
async with aiomqtt.Client(
hostname=mqtt_host,
port=mqtt_port,
username=username,
password=password,
timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
) as client:
await client.subscribe(response_topic)
# Give the broker a brief window to register the subscription
# before we publish the request. Without this, brokers that
# race SUBACK with our PUBLISH may deliver the response before
# the subscription is active and we'd hang until timeout.
await asyncio.sleep(0.1)
await client.publish(request_topic, json.dumps(request_payload))
async def _wait_for_response() -> None:
async for message in client.messages:
if str(message.topic) != response_topic:
continue
raw = message.payload
try:
payload_str = (
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
)
response_payload.update(json.loads(payload_str))
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(f"Malformed MQTT response: {exc}") from exc
return
await asyncio.wait_for(_wait_for_response(), timeout=response_timeout)
except aiomqtt.MqttError as exc:
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
except asyncio.TimeoutError as exc:
raise TimeoutError("Timed out waiting for MQTT response") from exc
if not response_payload:
raise ValueError("Empty MQTT response received")
return response_payload
async def test_connection(
mqtt_host: str,
mqtt_port: int,
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> bool:
"""Attempt a quick MQTT connection to verify broker reachability.
Returns True on success, raises ConnectionError/TimeoutError on failure.
"""
if aiomqtt is None: # pragma: no cover
raise ImportError("aiomqtt is required")
tls_context = _build_tls_context(tls_insecure) if tls else None
try:
async with aiomqtt.Client(
hostname=mqtt_host,
port=mqtt_port,
username=username,
password=password,
timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
):
return True
except aiomqtt.MqttError as exc:
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
except asyncio.TimeoutError as exc:
raise TimeoutError("Connection to broker timed out") from exc
-241
View File
@@ -1,241 +0,0 @@
"""Collapse *true* duplicate canvas nodes: same ``ieee_address`` **and** same
``design_id`` (i.e. the same device placed twice on the *same* canvas).
The same device legitimately appears on multiple canvases — placing a device on
another design creates a second :class:`Node` for that IEEE by design (see the
per-design guard in ``bulk_approve_devices``). Those cross-design rows are NOT
duplicates and must be preserved. Only two rows sharing both the IEEE *and* the
design are corrupt, and only those are collapsed here.
This module provides an idempotent, **loss-free** repair: per ``(ieee,
design_id)`` group with >1 node it keeps the oldest as canonical, merges the
extras' data into it (properties, missing scalar fields, services), re-points
every edge and ``parent_id`` reference onto the canonical node, then deletes the
extras. Edges are de-duplicated and self-loops dropped after re-pointing so no
dangling or redundant links remain.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Edge, Node
from app.services.zigbee_service import merge_zigbee_properties
logger = logging.getLogger(__name__)
def _ip_tokens(ip: str | None) -> list[str]:
"""Split a Node ``ip`` field into individual, whitespace-trimmed addresses.
The canvas stores several addresses in one comma-separated string once a
user adds e.g. an IPv6 address, so identity matching must compare per token.
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
async def find_duplicate_node(
db: AsyncSession,
design_id: str | None,
ip: str | None,
mac: str | None,
ieee: str | None = None,
) -> dict[str, Any] | None:
"""Return conflict details if an equivalent node (same ieee, ip OR mac)
already sits on ``design_id``, else ``None``.
Scoped to a single design on purpose: the same device may legitimately
appear on several canvases (one :class:`Node` per design). Only a second
node for the same ieee/ip/mac on the *same* design is a duplicate — which
the create/approve endpoints turn into a 409 so the UI can offer "go to
existing" vs "add duplicate anyway", uniformly for IEEE (Zigbee/Z-Wave) and
plain IP/ARP hosts.
(:func:`dedupe_nodes_by_ieee` still repairs *pre-existing* same-canvas IEEE
duplicates; this guard prevents new ones unless the user forces them.)
"""
ip_toks = _ip_tokens(ip)
conds = []
if ieee:
conds.append(Node.ieee_address == ieee)
# A node's ip may hold several comma-separated addresses (e.g. an IPv6 added
# before the IPv4), so narrow with a substring match then confirm per-token
# in Python — exact ``Node.ip == ip`` would miss those rows (issue #258).
for tok in ip_toks:
conds.append(Node.ip.contains(tok))
if mac:
conds.append(Node.mac == mac)
if not conds:
return None
candidates = (
await db.execute(
select(Node).where(Node.design_id == design_id, or_(*conds))
)
).scalars().all()
# Confirm a real match (the ip ``contains`` above can false-positive, e.g.
# "1.2.3.4" inside "1.2.3.40"), preferring ieee > ip > mac.
def _matches(node: Node) -> tuple[str, str | None] | None:
if ieee and node.ieee_address == ieee:
return "ieee", node.ieee_address
node_toks = set(_ip_tokens(node.ip))
hit = next((t for t in ip_toks if t in node_toks), None)
if hit is not None:
return "ip", hit
if mac and node.mac == mac:
return "mac", mac
return None
existing = None
matched: tuple[str, str | None] | None = None
for node in candidates:
m = _matches(node)
if m is not None:
existing, matched = node, m
break
if existing is None or matched is None:
return None
match, value = matched
return {
"duplicate": True,
"existing_node_id": existing.id,
"existing_label": existing.label,
"match": match,
"value": value,
}
# Scalar Node fields worth carrying over from a duplicate when the canonical
# node has no value. Positions, design, parent and identity fields are left on
# the canonical node untouched (that's the row the user actually placed).
_FILLABLE_FIELDS = (
"hostname",
"ip",
"mac",
"os",
"check_method",
"check_target",
"notes",
"cpu_count",
"cpu_model",
"ram_gb",
"disk_gb",
"custom_icon",
"last_seen",
"last_scan",
)
def _merge_services(
a: list[Any] | None, b: list[Any] | None
) -> list[Any]:
"""Union two service lists, de-duplicated, order-stable."""
out: list[Any] = list(a or [])
seen = {repr(s) for s in out}
for s in b or []:
if repr(s) not in seen:
out.append(s)
seen.add(repr(s))
return out
def _merge_into_canonical(canonical: Node, dup: Node) -> None:
"""Fold ``dup``'s data into ``canonical`` in place (no field lost)."""
canonical.properties = merge_zigbee_properties(
canonical.properties, dup.properties or []
)
canonical.services = _merge_services(canonical.services, dup.services)
for field in _FILLABLE_FIELDS:
if getattr(canonical, field, None) in (None, "") and getattr(dup, field, None) not in (None, ""):
setattr(canonical, field, getattr(dup, field))
# Prefer a human label over a bare IEEE/hex fallback.
if (not canonical.label or canonical.label == canonical.ieee_address) and dup.label:
canonical.label = dup.label
async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
"""Merge duplicate nodes sharing an ``ieee_address`` AND ``design_id``.
Returns the number of nodes removed. Idempotent: a no-op when every
``(ieee, design)`` pair maps to at most one node. Nodes with the same IEEE
on *different* designs are left untouched (valid cross-canvas placement).
Does not commit — the caller owns the transaction.
"""
rows = (
await db.execute(
select(Node)
.where(Node.ieee_address.is_not(None))
.order_by(Node.ieee_address, Node.created_at, Node.id)
)
).scalars().all()
groups: dict[tuple[str, str | None], list[Node]] = {}
for node in rows:
groups.setdefault((node.ieee_address, node.design_id), []).append(node) # type: ignore[arg-type]
removed = 0
for (ieee, _design), nodes in groups.items():
if len(nodes) < 2:
continue
canonical, *dups = nodes # oldest first (ordered above)
dup_ids = {d.id for d in dups}
for dup in dups:
_merge_into_canonical(canonical, dup)
# Re-point edges + parents, then drop self-loops / duplicates.
edges = (
await db.execute(
select(Edge).where(
Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids)
)
)
).scalars().all()
for edge in edges:
if edge.source in dup_ids:
edge.source = canonical.id
if edge.target in dup_ids:
edge.target = canonical.id
# Re-point children whose parent was a duplicate.
children = (
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
).scalars().all()
for child in children:
child.parent_id = canonical.id
# Collapse self-loops and now-redundant parallel edges.
all_edges = (
await db.execute(
select(Edge).where(
(Edge.source == canonical.id) | (Edge.target == canonical.id)
)
)
).scalars().all()
seen_pairs: set[tuple[str, str, str]] = set()
for edge in all_edges:
if edge.source == edge.target:
await db.delete(edge)
continue
key = (edge.source, edge.target, edge.type)
if key in seen_pairs:
await db.delete(edge)
continue
seen_pairs.add(key)
await db.flush()
for dup in dups:
await db.delete(dup)
removed += 1
logger.info(
"Deduped IEEE %s: merged %d duplicate node(s) into %s",
ieee, len(dups), canonical.id,
)
if removed:
await db.flush()
return removed
-424
View File
@@ -1,424 +0,0 @@
"""Proxmox VE inventory service: fetch hosts + VMs + LXC via the PVE REST API.
Mirrors the Zigbee/Z-Wave import pipeline, but talks to the Proxmox VE REST API
(``/api2/json``) over HTTPS with an API token instead of MQTT. It returns plain
homelable node dicts + parent→child edge hints; DB persistence lives in the
route layer (``app.api.routes.proxmox``).
Auth uses a Proxmox **API token** (never a password):
``Authorization: PVEAPIToken=<token_id>=<secret>`` where ``token_id`` looks like
``user@realm!tokenname``. A read-only ``PVEAuditor`` role is all that is needed.
"""
from __future__ import annotations
import logging
import re
from typing import Any
import httpx
from app.services.mac_utils import normalize_mac
from app.services.zigbee_service import merge_zigbee_properties
logger = logging.getLogger(__name__)
# Reuse the zigbee property-merge contract verbatim (same NodeProperty shape +
# visibility-preservation rules) for re-sync updates.
merge_proxmox_properties = merge_zigbee_properties
_CONNECT_TIMEOUT = 8.0
_READ_TIMEOUT = 20.0
_BYTES_PER_GB = 1024 ** 3
# net0 config line: "name=eth0,bridge=vmbr0,ip=192.168.1.5/24,gw=..."
_LXC_IP_RE = re.compile(r"(?:^|,)ip=([0-9]{1,3}(?:\.[0-9]{1,3}){3})(?:/\d+)?")
# NIC MAC inside a net0 string — qemu "virtio=BC:24:11:..,bridge=.." or lxc
# "..,hwaddr=BC:24:11:..,..". A bare 6-octet MAC match works for both forms.
_NET_MAC_RE = re.compile(r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
def _sanitize_proxmox_error(exc: BaseException) -> str:
"""Return a generic, credential-free message for a Proxmox/HTTP error.
Raw httpx errors can echo the request URL and, worse, an
``Authorization: PVEAPIToken=...=<secret>`` header in some stacks. Map known
patterns to coarse categories so the token never reaches an API client. The
original exception is logged at WARNING for operators.
"""
logger.warning("Proxmox error (sanitized for client): %r", exc)
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
if code in (401, 403):
return "Authentication failed — check the API token and its permissions"
if code == 404:
return "Proxmox API path not found — is this a Proxmox VE host?"
return f"Proxmox API returned HTTP {code}"
raw = str(exc).lower()
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
return "Proxmox host could not be resolved"
if "refused" in raw:
return "Connection refused by Proxmox host"
if "certificate" in raw or "ssl" in raw or "tls" in raw:
return "TLS verification failed — enable 'skip TLS verify' for self-signed certs"
if "timed out" in raw or "timeout" in raw:
return "Connection to Proxmox host timed out"
return "Proxmox connection failed"
def _auth_header(token_id: str, token_secret: str) -> dict[str, str]:
return {"Authorization": f"PVEAPIToken={token_id}={token_secret}"}
def _gb(value: Any) -> float | None:
"""Convert a byte count to GB (1 decimal). None/0 → None."""
try:
num = float(value)
except (TypeError, ValueError):
return None
if num <= 0:
return None
return round(num / _BYTES_PER_GB, 1)
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _guest_type_to_homelable(kind: str) -> str:
"""qemu → vm, lxc → lxc (both existing homelable node types)."""
return "vm" if kind == "qemu" else "lxc"
def _extract_qemu_ip(agent_payload: dict[str, Any] | None) -> str | None:
"""Pull the first non-loopback IPv4 from a qemu guest-agent interfaces reply."""
if not agent_payload:
return None
result = agent_payload.get("result")
if not isinstance(result, list):
return None
for iface in result:
if not isinstance(iface, dict):
continue
for addr in iface.get("ip-addresses") or []:
if not isinstance(addr, dict):
continue
if addr.get("ip-address-type") != "ipv4":
continue
ip = addr.get("ip-address")
if isinstance(ip, str) and ip and not ip.startswith("127."):
return ip
return None
def _extract_lxc_ip(config_payload: dict[str, Any] | None) -> str | None:
"""Parse a static IPv4 from an LXC ``net0`` config string (skip dhcp)."""
if not config_payload:
return None
net0 = config_payload.get("net0")
if not isinstance(net0, str):
return None
match = _LXC_IP_RE.search(net0)
return match.group(1) if match else None
def _extract_net_mac(config_payload: dict[str, Any] | None) -> str | None:
"""Parse the NIC MAC from a qemu/lxc ``net0`` config string (normalized).
Works agent-free for both guest kinds and for stopped guests — the sole
identity we can reliably cross-match against an ARP-scanned device.
"""
if not config_payload:
return None
net0 = config_payload.get("net0")
if not isinstance(net0, str):
return None
match = _NET_MAC_RE.search(net0)
return normalize_mac(match.group(1)) if match else None
def _host_node(raw: dict[str, Any]) -> dict[str, Any] | None:
"""Build a homelable ``proxmox`` host node from a ``/nodes`` entry."""
name = raw.get("node")
if not name:
return None
ieee = f"pve-node-{name}"
return {
"id": ieee,
"label": name,
"type": "proxmox",
"ieee_address": ieee,
"hostname": name,
"ip": raw.get("ip") or None,
"status": "online" if raw.get("status") == "online" else "offline",
"cpu_count": _int_or_none(raw.get("maxcpu")),
"ram_gb": _gb(raw.get("maxmem")),
"disk_gb": _gb(raw.get("maxdisk")),
"vendor": "Proxmox VE",
"model": None,
"parent_ieee": None,
}
def _guest_node(
raw: dict[str, Any], host_name: str, kind: str, ip: str | None, mac: str | None = None
) -> dict[str, Any] | None:
"""Build a homelable vm/lxc node from a ``/qemu`` or ``/lxc`` list entry."""
vmid = raw.get("vmid")
if vmid is None:
return None
ieee = f"pve-{host_name}-{vmid}"
node_type = _guest_type_to_homelable(kind)
name = raw.get("name") or f"{node_type}-{vmid}"
return {
"id": ieee,
"label": name,
"type": node_type,
"ieee_address": ieee,
"hostname": name,
"ip": ip,
"mac": mac,
"status": "online" if raw.get("status") == "running" else "offline",
"cpu_count": _int_or_none(raw.get("maxcpu") or raw.get("cpus")),
"ram_gb": _gb(raw.get("maxmem")),
"disk_gb": _gb(raw.get("maxdisk")),
"vendor": "Proxmox VE",
"model": kind.upper(),
"vmid": vmid,
"parent_ieee": f"pve-node-{host_name}",
}
def build_proxmox_properties(node: dict[str, Any]) -> list[dict[str, Any]]:
"""Build a NodeProperty list for a Proxmox device (specs + identity).
Icons match the existing hardware-property convention (``Cpu`` /
``MemoryStick`` / ``HardDrive``). All rows default ``visible=False`` — the
user opts in from the right panel, same as the mesh importers."""
props: list[dict[str, Any]] = []
vmid = node.get("vmid")
if vmid is not None:
props.append({"key": "VMID", "value": str(vmid), "icon": None, "visible": False})
if node.get("model"):
props.append({"key": "Kind", "value": node["model"], "icon": None, "visible": False})
if node.get("cpu_count") is not None:
props.append({"key": "CPU Cores", "value": str(node["cpu_count"]), "icon": "Cpu", "visible": False})
if node.get("ram_gb") is not None:
props.append({"key": "RAM", "value": f"{node['ram_gb']} GB", "icon": "MemoryStick", "visible": False})
if node.get("disk_gb") is not None:
props.append({"key": "Disk", "value": f"{node['disk_gb']} GB", "icon": "HardDrive", "visible": False})
props.append({"key": "Source", "value": "Proxmox VE", "icon": None, "visible": False})
return props
def build_proxmox_cluster_links(nodes: list[dict[str, Any]]) -> list[tuple[str, str]]:
"""Chain host nodes (``type == 'proxmox'``) into cluster links.
Hosts from one import belong to the same cluster, so they are linked
host↔host (rendered as ``cluster`` edges via left/right handles, distinct
from the vertical host→guest ``virtual`` edges). Returns consecutive
``(source_ieee, target_ieee)`` pairs, or ``[]`` for a single host. Mirrors
the frontend ``buildProxmoxClusterEdges``.
"""
hosts = [n["ieee_address"] for n in nodes if n.get("type") == "proxmox" and n.get("ieee_address")]
if len(hosts) < 2:
return []
return [(hosts[i], hosts[i + 1]) for i in range(len(hosts) - 1)]
def _parse_inventory(
hosts_raw: list[dict[str, Any]],
guests_by_host: dict[str, list[dict[str, Any]]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Assemble (nodes, edges) from fetched host + guest data.
``guests_by_host`` maps host name → list of already-normalized guest node
dicts. Edges are one host→guest link per guest (materialized as canvas
edges on approval, mirroring the zigbee/zwave link mechanism).
"""
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in hosts_raw:
host = _host_node(raw)
if host is None or host["id"] in seen:
continue
seen.add(host["id"])
nodes.append(host)
for host_name, guests in guests_by_host.items():
host_ieee = f"pve-node-{host_name}"
for guest in guests:
if guest["id"] in seen:
continue
seen.add(guest["id"])
nodes.append(guest)
edges.append({"source": host_ieee, "target": guest["id"]})
return nodes, edges
async def _get_json(client: httpx.AsyncClient, path: str) -> Any:
resp = await client.get(path)
resp.raise_for_status()
return resp.json().get("data")
async def _token_has_permissions(client: httpx.AsyncClient) -> bool:
"""True if the API token holds *any* ACL.
Proxmox list endpoints (``/qemu``, ``/lxc``) silently return an empty
``200`` when the token lacks ``VM.Audit`` — indistinguishable from a host
that genuinely has no guests. ``GET /access/permissions`` returns ``{}`` for
a token with no ACL at all, which is the common misconfiguration (a
privilege-separated token created without its own permission). Best-effort:
on any error assume permissions exist so we never block a valid import.
"""
try:
perms = await _get_json(client, "/access/permissions")
except httpx.HTTPError:
return True
return bool(perms) if isinstance(perms, dict) else True
async def fetch_proxmox_inventory(
host: str,
port: int,
token_id: str,
token_secret: str,
verify_tls: bool = True,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Fetch hosts + VMs + LXC from a Proxmox VE host, return (nodes, edges).
Raises:
ConnectionError: transport/DNS/TLS failures (sanitized message).
ValueError: malformed API response.
"""
base_url = f"https://{host}:{port}/api2/json"
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
guests_by_host: dict[str, list[dict[str, Any]]] = {}
try:
async with httpx.AsyncClient(
base_url=base_url,
headers=_auth_header(token_id, token_secret),
verify=verify_tls,
timeout=timeout,
) as client:
hosts_raw = await _get_json(client, "/nodes")
if not isinstance(hosts_raw, list):
raise ValueError("Malformed /nodes response")
for host_entry in hosts_raw:
name = host_entry.get("node")
if not name or host_entry.get("status") != "online":
# Offline nodes can't be queried for guests; still shown as host.
continue
guests_by_host[name] = await _fetch_host_guests(client, name)
except httpx.HTTPStatusError as exc:
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
except httpx.HTTPError as exc:
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
return _parse_inventory(hosts_raw, guests_by_host)
async def _fetch_host_guests(
client: httpx.AsyncClient, host_name: str
) -> list[dict[str, Any]]:
"""Fetch qemu + lxc guests for one host, resolving guest IPs best-effort."""
guests: list[dict[str, Any]] = []
for kind in ("qemu", "lxc"):
try:
entries = await _get_json(client, f"/nodes/{host_name}/{kind}")
except httpx.HTTPError as exc:
logger.warning("Proxmox %s list failed for %s: %s", kind, host_name, exc)
continue
if not isinstance(entries, list):
continue
for raw in entries:
ip, mac = await _resolve_guest_net(client, host_name, kind, raw)
node = _guest_node(raw, host_name, kind, ip, mac)
if node:
guests.append(node)
return guests
async def _resolve_guest_net(
client: httpx.AsyncClient, host_name: str, kind: str, raw: dict[str, Any]
) -> tuple[str | None, str | None]:
"""Best-effort (ip, mac) for a guest. Never raises.
- MAC comes from the guest ``/config`` net0 line for both kinds (agent-free,
works for stopped guests) — the cross-source dedup key.
- IP: qemu → guest agent (running only); lxc → static net0 config.
Partial results are returned even if a later call fails (e.g. config MAC is
kept when the qemu agent call errors).
"""
vmid = raw.get("vmid")
if vmid is None:
return None, None
ip: str | None = None
mac: str | None = None
try:
if kind == "qemu":
config = await _get_json(client, f"/nodes/{host_name}/qemu/{vmid}/config")
mac = _extract_net_mac(config)
if raw.get("status") == "running":
data = await _get_json(
client, f"/nodes/{host_name}/qemu/{vmid}/agent/network-get-interfaces"
)
ip = _extract_qemu_ip(data)
else:
config = await _get_json(client, f"/nodes/{host_name}/lxc/{vmid}/config")
ip = _extract_lxc_ip(config)
mac = _extract_net_mac(config)
except httpx.HTTPError:
# Guest agent not installed / container stopped / no perms. Keep whatever
# was resolved before the failure.
pass
return ip, mac
async def test_proxmox_connection(
host: str,
port: int,
token_id: str,
token_secret: str,
verify_tls: bool = True,
) -> tuple[bool, str]:
"""Quick reachability + auth check via ``GET /version``.
Returns (connected, message). Never raises credentials outward.
"""
base_url = f"https://{host}:{port}/api2/json"
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
try:
async with httpx.AsyncClient(
base_url=base_url,
headers=_auth_header(token_id, token_secret),
verify=verify_tls,
timeout=timeout,
) as client:
data = await _get_json(client, "/version")
has_perms = await _token_has_permissions(client)
version = (data or {}).get("version", "?") if isinstance(data, dict) else "?"
message = f"Connected to Proxmox VE {version}"
if not has_perms:
message += (
" — warning: this API token has no permissions, so VMs and LXC "
"will not be visible. Assign the PVEAuditor role at path '/' to the "
"token in Proxmox (Datacenter → Permissions → API Token Permission)."
)
return True, message
except httpx.HTTPError as exc:
return False, _sanitize_proxmox_error(exc)
except Exception as exc: # noqa: BLE001 — surface a safe message, log the rest
logger.exception("Unexpected error during Proxmox connection test")
return False, _sanitize_proxmox_error(exc)
+78 -591
View File
@@ -1,84 +1,17 @@
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
"""Network scanner: ARP sweep + nmap service detection."""
import asyncio
import ipaddress
import logging
import os
import re
import socket
import subprocess
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
from app.services.discovery_sources import add_source
from app.db.models import PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports
from app.services.mac_utils import normalize_mac
logger = logging.getLogger(__name__)
# Run IDs that have been requested to cancel (thread-safe via lock)
_cancelled_runs: set[str] = set()
_cancelled_lock = threading.Lock()
# Port list for service detection (Phase 2)
_EXTRA_PORTS = (
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
"16686,34567,37777,51413,64738"
)
# nmap -p accepts "N" or "N-M"; user ranges are validated against this.
_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
@dataclass
class DeepScanOptions:
"""Per-scan deep-scan settings (None/empty → standard scan, today's behaviour)."""
http_ranges: list[str] = field(default_factory=list)
http_probe_enabled: bool = False
verify_tls: bool = False
def _valid_port_range(spec: str) -> bool:
if not _PORT_RANGE_RE.match(spec):
return False
parts = [int(p) for p in spec.split("-")]
if any(p < 1 or p > 65535 for p in parts):
return False
return len(parts) == 1 or parts[0] <= parts[1]
def _build_port_spec(http_ranges: list[str] | None) -> str:
"""Combine the default port list with validated user ranges for nmap -p."""
if not http_ranges:
return _EXTRA_PORTS
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
if not extra:
return _EXTRA_PORTS
return _EXTRA_PORTS + "," + ",".join(extra)
_MDNS_SERVICE_TYPES = [
"_http._tcp.local.",
"_shelly._tcp.local.",
"_esphomelib._tcp.local.",
"_hap._tcp.local.", # HomeKit Accessory Protocol
"_mqtt._tcp.local.",
"_device-info._tcp.local.",
]
try:
import nmap
_NMAP_AVAILABLE = True
@@ -86,24 +19,50 @@ except ImportError:
_NMAP_AVAILABLE = False
logger.warning("python-nmap not available — scanner will run in mock mode")
def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""Run nmap -sV --open on target, return list of host dicts."""
if not _NMAP_AVAILABLE:
return _mock_scan(target)
nm = nmap.PortScanner()
try:
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
_ZEROCONF_AVAILABLE = True
except ImportError:
_ZEROCONF_AVAILABLE = False
logger.warning("zeroconf not available — mDNS discovery disabled")
# Home lab port range: standard top-1000 + common self-hosted service ports
extra_ports = (
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
)
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
except Exception as exc:
logger.error("nmap scan failed: %s", exc)
raise RuntimeError(str(exc)) from exc
def request_cancel(run_id: str) -> None:
"""Signal a running scan to stop early."""
with _cancelled_lock:
_cancelled_runs.add(run_id)
def _is_cancelled(run_id: str) -> bool:
with _cancelled_lock:
return run_id in _cancelled_runs
hosts = []
for host in nm.all_hosts():
if nm[host].state() != "up":
continue
open_ports = []
for proto in nm[host].all_protocols():
for port, info in nm[host][proto].items():
if info["state"] == "open":
open_ports.append({
"port": port,
"protocol": proto,
"banner": info.get("product", "") + " " + info.get("version", ""),
})
hosts.append({
"ip": host,
"hostname": _resolve_hostname(host),
"mac": nm[host].get("addresses", {}).get("mac"),
"os": _extract_os(nm, host),
"open_ports": open_ports,
})
return hosts
def _resolve_hostname(ip: str) -> str | None:
@@ -123,336 +82,6 @@ def _extract_os(nm: object, host: str) -> str | None:
return None
def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
"""
Read the OS ARP cache for recently-seen hosts in the target network.
Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
Supplements nmap discovery — catches IoT and devices with all ports filtered.
"""
try:
net = ipaddress.ip_network(network, strict=False)
found: dict[str, dict[str, Any]] = {}
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
proc_arp = "/proc/net/arp"
try:
with open(proc_arp) as f:
for line in f.readlines()[1:]: # skip header row
parts = line.split()
if len(parts) >= 4:
ip, mac = parts[0], parts[3]
if mac == "00:00:00:00:00:00":
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {
"ip": ip, "mac": mac,
"hostname": _resolve_hostname(ip),
"os": None, "open_ports": [],
}
except ValueError:
pass
# /proc/net/arp opened successfully — return whatever we found (may be empty)
# Don't fall through to `arp -a` since we're on Linux
return found
except FileNotFoundError:
pass # Not Linux — fall through to macOS `arp -a`
# macOS: parse `arp -a` output
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
for line in result.stdout.splitlines():
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
if not m:
continue
ip, mac = m.group(1), m.group(2)
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
except ValueError:
pass
return found
except Exception as exc:
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
return {}
async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[str, Any]]:
"""
Phase 1: Concurrent ICMP ping sweep + ARP cache.
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
Supplements with the OS ARP cache to catch devices that block ICMP.
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
"""
net = ipaddress.ip_network(target, strict=False)
all_ips = [str(ip) for ip in net.hosts()]
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
sem = asyncio.Semaphore(50)
async def _ping(ip: str) -> str | None:
async with sem:
try:
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "1", ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return ip if proc.returncode == 0 else None
except Exception:
return None
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
# Cancelled during the sweep — bail before the (potentially long) Phase 2
# port scan. Returning empty makes _nmap_scan skip nmap entirely.
if run_id is not None and _is_cancelled(run_id):
logger.info("[Phase 1] %s — scan cancelled, skipping hostname/ARP enrichment", target)
return {}
# ARP cache: catch devices that block ICMP but were recently active,
# and enrich ping-alive hosts with their MAC addresses.
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
alive: dict[str, dict[str, Any]] = {}
for ip in alive_ips:
mac = arp_cache.get(ip, {}).get("mac")
hostname = await asyncio.to_thread(_resolve_hostname, ip)
logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
for ip, host in arp_cache.items():
if ip not in alive:
logger.info(
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
)
alive[ip] = host
return alive
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
"""
Phase 2 — single-IP port scan with service detection.
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
Two decoupled nmap passes (issue #277):
Pass A — port discovery only (``--open``, no ``-sV``). Fast and reliable;
its open ports are authoritative and are never discarded.
Pass B — version detection (``-sV``) scoped to the ports Pass A found,
bounded by ``--host-timeout``. Best-effort: if it times out or
fails (e.g. a TLS port stalls plaintext probes), the Pass A ports
are kept with empty banners instead of the whole host being lost.
"""
ip = host_dict["ip"]
logger.info("[Phase 2] Scanning %s ...", ip)
if not _NMAP_AVAILABLE:
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
return host_dict
# -sS (SYN) needs root; -sT (connect) works without it. nmap auto-selects
# -sT without root but being explicit avoids edge cases.
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
# --- Pass A: port discovery (no -sV, no host-timeout) ---
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
nm_disc = nmap.PortScanner()
try:
nm_disc.scan(hosts=ip, arguments=discovery_args)
except Exception as exc:
logger.warning("[Phase 2] nmap discovery FAILED for %s (%s: %s) — skipping port scan",
ip, type(exc).__name__, exc)
return host_dict
if ip not in nm_disc.all_hosts():
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
return host_dict
open_ports = []
for proto in nm_disc[ip].all_protocols():
for port, info in nm_disc[ip][proto].items():
if info["state"] == "open":
open_ports.append({"port": port, "protocol": proto, "banner": ""})
if not open_ports:
logger.info("[Phase 2] %s — 0 open ports detected", ip)
host_dict["open_ports"] = []
if not host_dict["mac"]:
host_dict["mac"] = nm_disc[ip].get("addresses", {}).get("mac")
return host_dict
# --- Pass B: version detection on the discovered ports (best-effort) ---
port_list = ",".join(str(p["port"]) for p in open_ports)
timeout = settings.scanner_version_host_timeout
version_args = f"{scan_type} -sV -T4 -Pn --host-timeout {timeout}s -p {port_list}"
logger.debug("[Phase 2] %s version args: %s", ip, version_args)
nm_ver = nmap.PortScanner()
banners: dict[tuple[str, int], str] = {}
ver_os = None
ver_mac = None
try:
nm_ver.scan(hosts=ip, arguments=version_args)
if ip in nm_ver.all_hosts():
for proto in nm_ver[ip].all_protocols():
for port, info in nm_ver[ip][proto].items():
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
if banner:
banners[(proto, port)] = banner
ver_os = _extract_os(nm_ver, ip)
ver_mac = nm_ver[ip].get("addresses", {}).get("mac")
else:
logger.info("[Phase 2] %s — version detection returned no results, keeping %d port(s) without banners",
ip, len(open_ports))
except Exception as exc:
logger.info("[Phase 2] %s — version detection failed (%s: %s), keeping %d port(s) without banners",
ip, type(exc).__name__, exc, len(open_ports))
for p in open_ports:
p["banner"] = banners.get((p["protocol"], p["port"]), "")
port_summary = ", ".join(
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
)
logger.info("[Phase 2] %s%d open port(s): %s", ip, len(open_ports), port_summary)
host_dict["open_ports"] = open_ports
if not host_dict["mac"]:
host_dict["mac"] = ver_mac or nm_disc[ip].get("addresses", {}).get("mac")
host_dict["os"] = ver_os
return host_dict
async def _nmap_port_scan(
alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS,
run_id: str | None = None,
) -> list[dict[str, Any]]:
"""
Phase 2: Per-IP service detection with bounded concurrency.
Each host is scanned independently in a thread — no inter-host timeout interference.
Up to 10 hosts scanned concurrently.
"""
if not alive:
return []
logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
semaphore = asyncio.Semaphore(10)
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
# Once cancelled, skip the expensive nmap call for every host still
# queued behind the semaphore — return it unscanned so the gather
# unwinds fast instead of blocking the stop for minutes.
if run_id is not None and _is_cancelled(run_id):
return host_dict
return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec)
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
results = []
for item in raw:
if isinstance(item, BaseException):
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
else:
results.append(item)
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
return results
async def _nmap_scan(
target: str, port_spec: str = _EXTRA_PORTS, run_id: str | None = None
) -> list[dict[str, Any]]:
"""
Two-phase scan for a CIDR range.
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
``run_id`` lets each phase poll for cancellation so a stop request takes
effect mid-range instead of only at CIDR/host boundaries in run_scan.
"""
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
if run_id is not None and _is_cancelled(run_id):
logger.info("[Scan] %s — cancelled before start, skipping", target)
return []
if not _NMAP_AVAILABLE:
logger.warning("[Scan] nmap not available — returning mock data")
return _mock_scan(target)
try:
alive = await _ping_sweep(target, run_id=run_id)
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
len(alive), target, ", ".join(sorted(alive.keys())))
except Exception as exc:
logger.error("Phase 1 ping sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc
return await _nmap_port_scan(alive, port_spec, run_id=run_id)
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
"""
Passive mDNS/Bonjour sweep.
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
Runs for `timeout` seconds then returns what it found.
"""
if not _ZEROCONF_AVAILABLE:
return []
import ipaddress
found_services: list[tuple[str, str]] = []
def _on_change(
zeroconf: Any,
service_type: str,
name: str,
state_change: Any,
) -> None:
if state_change == ServiceStateChange.Added:
found_services.append((service_type, name))
discovered: dict[str, dict[str, Any]] = {}
try:
async with AsyncZeroconf() as azc:
browser = AsyncServiceBrowser(
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
)
await asyncio.sleep(timeout)
await browser.async_cancel()
for service_type, name in found_services:
try:
info = AsyncServiceInfo(service_type, name)
await info.async_request(azc.zeroconf, 3000)
if not info.addresses:
continue
ip = str(ipaddress.IPv4Address(info.addresses[0]))
if ip in discovered:
continue
discovered[ip] = {
"ip": ip,
"hostname": info.server,
"mac": None,
"os": None,
"open_ports": (
[{"port": info.port, "protocol": "tcp", "banner": ""}]
if info.port else []
),
}
except Exception as exc:
logger.debug("mDNS resolution failed for %s: %s", name, exc)
except Exception as exc:
logger.warning("mDNS discovery error: %s", exc)
logger.info("mDNS discovery found %d device(s)", len(discovered))
return list(discovered.values())
def _mock_scan(target: str) -> list[dict[str, Any]]:
"""Return fake results for dev/test environments without nmap."""
return [
@@ -469,217 +98,75 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
]
async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
"""Collapse duplicate non-hidden inventory rows that share an IP into one.
Keeps an ``approved`` row when present (it carries canvas-link semantics),
otherwise the oldest row, and deletes the rest. Returns the number deleted.
"""
rows = (await db.execute(
select(PendingDevice)
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
.order_by(PendingDevice.discovered_at)
)).scalars().all()
by_ip: dict[str, list[PendingDevice]] = {}
for row in rows:
if row.ip is None: # guarded by the query, but keeps the type checker happy
continue
by_ip.setdefault(row.ip, []).append(row)
deleted = 0
for group in by_ip.values():
if len(group) < 2:
continue
keep = next((r for r in group if r.status == "approved"), group[0])
for dup in group:
if dup is not keep:
await db.delete(dup)
deleted += 1
if deleted:
await db.commit()
return deleted
async def run_scan(
ranges: list[str],
db: AsyncSession,
run_id: str,
deep_scan: DeepScanOptions | None = None,
) -> None:
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices."""
# Avoid circular import
from sqlalchemy import select
from app.api.routes.status import broadcast_scan_update
deep_scan = deep_scan or DeepScanOptions()
port_spec = _build_port_spec(deep_scan.http_ranges)
devices_found = 0
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try:
# Validate all ranges are valid CIDRs before passing anything to nmap
for r in ranges:
try:
ipaddress.ip_network(r, strict=False)
except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None
for cidr in ranges:
# Run nmap in a thread pool — does not block the event loop
hosts = await asyncio.to_thread(_nmap_scan, cidr)
# Pre-fetch hidden IPs once — avoids N+1 queries per host.
# Devices already on a canvas are intentionally NOT suppressed: they stay
# in the inventory and are badged "In N canvas" via per-request correlation.
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
for host in hosts:
services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
# Update existing pending device or create a new one
existing_result = await db.execute(
select(PendingDevice).where(
PendingDevice.ip == host["ip"],
PendingDevice.status == "pending",
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
# Collapse any pre-existing duplicate inventory rows (same IP, non-hidden)
# left over from older scans, so the device shows up exactly once even if
# it isn't re-discovered this run (e.g. now offline).
await _dedupe_pending_by_ip(db)
# Start mDNS discovery in the background while nmap scans run
mdns_task = asyncio.create_task(_mdns_discover())
# Track IPs found by nmap so mDNS doesn't duplicate them
nmap_ips: set[str] = set()
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
nonlocal devices_found
ip = host["ip"]
# Skip only user-hidden devices. On-canvas devices are kept so they
# surface in the inventory with a canvas-presence badge.
if ip in hidden_ips:
logger.debug("Skipping %s — hidden by user", ip)
return
open_ports = host["open_ports"]
# Deep-scan HTTP probe: enrich open ports with title/header signals so
# fingerprint can confirm services on custom ports. No-op when disabled
# or when the host has no open ports (e.g. mDNS-only discovery).
if deep_scan.http_probe_enabled and open_ports:
open_ports = await probe_open_ports(
ip, open_ports, verify_tls=deep_scan.verify_tls
)
norm_mac = normalize_mac(host.get("mac"))
services = fingerprint_ports(open_ports)
suggested_type = suggest_node_type(open_ports, norm_mac)
# One inventory row per device. Match by IP OR MAC across pending AND
# approved so a re-scan refreshes the existing row instead of spawning
# a duplicate — and so a device previously imported from Proxmox (which
# may have no IP but a known NIC MAC) reconciles with this scan instead
# of doubling up. Hidden rows are already skipped above.
match_cond = [PendingDevice.ip == ip]
if norm_mac:
match_cond.append(PendingDevice.mac == norm_mac)
existing_rows = (await db.execute(
select(PendingDevice)
.where(or_(*match_cond), PendingDevice.status != "hidden")
.order_by(PendingDevice.discovered_at)
)).scalars().all()
if existing_rows:
# Prefer an approved row (it owns the canvas link semantics),
# otherwise the oldest. Collapse any leftover duplicates created
# by earlier scans.
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
for dup in existing_rows:
if dup is not keep:
await db.delete(dup)
keep.ip = keep.ip or ip # fill an IP a Proxmox import lacked
keep.mac = norm_mac or keep.mac
keep.hostname = host.get("hostname") or keep.hostname
keep.os = host.get("os") or keep.os
keep.services = services
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
# scan guess; the importer knows the true type.
if not (keep.ieee_address or "").startswith("pve-"):
keep.suggested_type = suggested_type
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
# status preserved — an approved device stays approved.
existing = existing_result.scalar_one_or_none()
if existing:
existing.mac = host.get("mac") or existing.mac
existing.hostname = host.get("hostname") or existing.hostname
existing.os = host.get("os") or existing.os
existing.services = services
existing.suggested_type = suggested_type
else:
db.add(PendingDevice(
ip=ip,
mac=norm_mac,
device = PendingDevice(
ip=host["ip"],
mac=host.get("mac"),
hostname=host.get("hostname"),
os=host.get("os"),
services=services,
suggested_type=suggested_type,
status="pending",
discovery_source=discovery_source,
discovery_sources=[discovery_source],
))
)
db.add(device)
devices_found += 1
# Stamp last_scan on any canvas node that matches this device by IP
# (or MAC, when known) so the inventory shows when the scanner last
# observed it. Match both the normalized and raw MAC so a legacy
# canvas node whose mac predates normalization still matches. Across
# designs.
node_match = [Node.ip == ip]
for m in {norm_mac, host.get("mac")}:
if m:
node_match.append(Node.mac == m)
matching_nodes = (await db.execute(
select(Node).where(or_(*node_match))
)).scalars().all()
scanned_at = datetime.now(timezone.utc)
for node in matching_nodes:
node.last_scan = scanned_at
# Commit immediately so the device is visible right away
await db.commit()
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
# nmap scan per CIDR — results stream in progressively
for cidr in ranges:
if _is_cancelled(run_id):
break
hosts = await _nmap_scan(cidr, port_spec, run_id=run_id)
for host in hosts:
if _is_cancelled(run_id):
break
nmap_ips.add(host["ip"])
await _process_host(host)
# Update ScanRun count once after all CIDR ranges
# Update running count on the scan run record
run = await db.get(ScanRun, run_id)
if run:
run.devices_found = devices_found
await db.commit()
# Collect mDNS results — task already has its own 4s internal timeout
if not _is_cancelled(run_id):
mdns_hosts = await mdns_task
# Push WS event so the frontend refreshes pending panel
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
for host in mdns_hosts:
if _is_cancelled(run_id):
break
if host["ip"] in nmap_ips:
continue # already processed with richer nmap data
await _process_host(host, discovery_source="mdns")
else:
mdns_task.cancel()
# Mark scan as done or cancelled
# Mark scan as done
run = await db.get(ScanRun, run_id)
if run:
run.status = "cancelled" if _is_cancelled(run_id) else "done"
run.status = "done"
run.devices_found = devices_found
run.finished_at = datetime.now(timezone.utc)
await db.commit()
except Exception as exc:
logger.error("Scan failed: %s", exc)
if mdns_task is not None and not mdns_task.done():
mdns_task.cancel()
run = await db.get(ScanRun, run_id)
if run:
run.status = "error"
run.error = str(exc)
run.finished_at = datetime.now(timezone.utc)
await db.commit()
finally:
with _cancelled_lock:
_cancelled_runs.discard(run_id)
+2 -110
View File
@@ -2,7 +2,6 @@
import asyncio
import logging
import socket
import sys
import time
from typing import Any
@@ -19,16 +18,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
if check_method == "none":
return {"status": "online", "response_time_ms": None}
# Use only the first IP when the field contains comma-separated addresses
raw_ip = ip.split(",")[0].strip() if ip else None
host = target or raw_ip
host = target or ip
if not host:
return {"status": "unknown", "response_time_ms": None}
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
# against arg-injection if a malicious admin sets target like "-O".
if host.startswith("-"):
logger.warning("Rejecting check target that starts with '-': %r", host)
return {"status": "unknown", "response_time_ms": None}
start = time.monotonic()
try:
@@ -64,37 +56,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
return {"status": "offline", "response_time_ms": None}
def _is_ipv6(host: str) -> bool:
"""True if host is a literal IPv6 address (bracketed or bare)."""
try:
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
return True
except OSError:
return False
async def _ping(host: str) -> bool:
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
#
# -W flag units differ by OS:
# Linux: seconds (-W 2 = 2s)
# macOS: milliseconds (-W 2000 = 2s)
# Windows: -w in ms (-w 2000 = 2s)
#
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
ipv6 = _is_ipv6(host)
if sys.platform == "win32":
family = ["-6"] if ipv6 else ["-4"]
args = ["ping", *family, "-n", "2", "-w", "2000", host]
elif sys.platform == "darwin":
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
else:
family = ["-6"] if ipv6 else []
args = ["ping", *family, "-c", "2", "-W", "2", host]
proc = await asyncio.create_subprocess_exec(
*args,
"ping", "-c", "1", "-W", "1", host,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
@@ -118,75 +82,3 @@ async def _tcp_connect(host: str, port: int) -> bool:
return True
except (TimeoutError, OSError, socket.gaierror):
return False
# --- Per-service status checks ---
# Ports that are not HTTP/web. These get NO status check — a service here stays
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
_NON_HTTP_PORTS = frozenset({
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
})
_HTTPS_PORTS = frozenset({443, 8443})
def _service_host(svc: dict[str, Any], host: str) -> str:
"""Bracket bare IPv6 literals for use in a URL."""
return f"[{host}]" if _is_ipv6(host) else host
async def check_service(svc: dict[str, Any], host: str | None) -> str:
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
so it keeps its category colour instead of flashing red. An open TCP socket
doesn't prove a non-web service is healthy, so we don't pretend it does.
"""
if not host or host.startswith("-"):
return "unknown"
if str(svc.get("protocol", "")).lower() == "udp":
return "unknown"
port = svc.get("port")
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
if port is not None and port in _NON_HTTP_PORTS:
return "unknown"
name = str(svc.get("service_name", "")).lower()
is_web = port is not None or "http" in name
if not is_web:
return "unknown"
try:
scheme = "https" if (
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
) else "http"
url_host = _service_host(svc, host)
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
return "online" if await _http_get(url, verify=False) else "offline"
except Exception as exc:
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
return "offline"
async def check_services(
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
) -> list[dict[str, Any]]:
"""Check every service against host concurrently (bounded).
Returns a list of {port, protocol, status} dicts, one per input service.
"""
sem = asyncio.Semaphore(concurrency)
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
async with sem:
status = await check_service(svc, host)
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
return await asyncio.gather(*[_one(s) for s in services]) if services else []
-341
View File
@@ -1,341 +0,0 @@
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
logger = logging.getLogger(__name__)
try:
import aiomqtt
except ImportError: # pragma: no cover
aiomqtt = None # type: ignore[assignment]
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
# Re-exported for backwards compatibility — these now live in mqtt_common.
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
def build_zigbee_properties(
ieee: str | None,
vendor: str | None,
model: str | None,
lqi: int | None,
) -> list[dict[str, Any]]:
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
Only includes a row when the value is non-empty. Shape matches the
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
New props default to ``visible=False`` — users opt in to showing them on
the canvas card from the right panel.
"""
props: list[dict[str, Any]] = []
if ieee:
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
if vendor:
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
if model:
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
if lqi is not None:
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
return props
def merge_zigbee_properties(
existing: list[dict[str, Any]] | None,
new_props: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Merge fresh zigbee props into an existing property list.
For keys already present: update ``value`` but preserve the user's
``visible`` choice. New keys are appended with whatever visibility the
caller gave them (hidden by default per ``build_zigbee_properties``).
Non-zigbee custom properties are preserved untouched.
"""
out = [dict(p) for p in (existing or [])]
by_key = {p.get("key"): p for p in out}
for np in new_props:
key = np.get("key")
if key in by_key:
by_key[key]["value"] = np.get("value")
else:
out.append(dict(np))
return out
def _z2m_type_to_homelable(device_type: str) -> str:
"""Map a Z2M device type string to a homelable node type."""
mapping = {
"Coordinator": "zigbee_coordinator",
"Router": "zigbee_router",
"EndDevice": "zigbee_enddevice",
}
return mapping.get(device_type, "zigbee_enddevice")
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
if not ieee:
return None
device_type: str = raw.get("type") or "EndDevice"
friendly_name: str = (
raw.get("friendlyName") or raw.get("friendly_name") or ieee
)
definition: dict[str, Any] = raw.get("definition") or {}
model: str | None = (
raw.get("modelID")
or raw.get("model")
or definition.get("model")
or None
)
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
return {
"id": ieee,
"label": friendly_name,
"type": _z2m_type_to_homelable(device_type),
"ieee_address": ieee,
"friendly_name": friendly_name,
"device_type": device_type,
"model": model,
"vendor": vendor,
"lqi": None,
"parent_id": None,
}
def parse_networkmap(
payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
Z2M raw response shape::
{
"data": {
"type": "raw",
"routes": false,
"value": {
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
"lqi": 200, "depth": 1}]
}
},
"status": "ok"
}
Older or alternate shapes may put nodes/links directly under ``data``.
Both are accepted.
"""
data: dict[str, Any] = payload.get("data") or {}
value = data.get("value")
container: dict[str, Any] = value if isinstance(value, dict) else data
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
raw_links: list[dict[str, Any]] = container.get("links") or []
if not isinstance(raw_nodes, list):
raise ValueError("Malformed networkmap: 'nodes' is not a list")
if not isinstance(raw_links, list):
raise ValueError("Malformed networkmap: 'links' is not a list")
nodes_list: list[dict[str, Any]] = []
seen_ids: set[str] = set()
coordinator_id: str | None = None
for entry in raw_nodes:
if not isinstance(entry, dict):
continue
node = _node_from_z2m(entry)
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
nodes_list.append(node)
if node["device_type"] == "Coordinator":
coordinator_id = node["id"]
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
# resolve which router an end device hangs off; do NOT emit edges directly
# from links. The final edge set is the strict parent→child tree built
# from parent_id below — that avoids duplicate edges and keeps the visual
# flow consistent (parent bottom → child top).
raw_edges: list[dict[str, Any]] = []
lqi_by_id: dict[str, int] = {}
for link in raw_links:
if not isinstance(link, dict):
continue
src_obj = link.get("source") or {}
tgt_obj = link.get("target") or {}
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
if not src or not tgt:
continue
if src not in seen_ids or tgt not in seen_ids:
continue
raw_edges.append({"source": src, "target": tgt})
lqi = link.get("lqi") or link.get("linkquality")
if isinstance(lqi, int) and tgt not in lqi_by_id:
lqi_by_id[tgt] = lqi
for node in nodes_list:
if node["id"] in lqi_by_id:
node["lqi"] = lqi_by_id[node["id"]]
# Build parent_id hierarchy: coordinator → routers → end devices
if coordinator_id:
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
for node in nodes_list:
if node["device_type"] == "Router":
node["parent_id"] = coordinator_id
elif node["device_type"] == "EndDevice":
parent = _find_parent_router(node["id"], router_ids, raw_edges)
node["parent_id"] = parent or coordinator_id
# Final edges = strict parent → child tree (one edge per non-coordinator)
edges_list: list[dict[str, Any]] = [
{"source": node["parent_id"], "target": node["id"]}
for node in nodes_list
if node.get("parent_id")
]
return nodes_list, edges_list
def _find_parent_router(
device_id: str,
router_ids: set[str],
edges: list[dict[str, Any]],
) -> str | None:
"""Return the first router that has a direct edge to device_id."""
for edge in edges:
src: str = edge["source"]
tgt: str = edge["target"]
if tgt == device_id and src in router_ids:
return src
if src == device_id and tgt in router_ids:
return tgt
return None
async def fetch_networkmap(
mqtt_host: str,
mqtt_port: int,
base_topic: str,
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
Raises:
TimeoutError: if the broker does not respond in time.
ConnectionError: if the broker cannot be reached.
ValueError: if the response payload is malformed.
"""
if aiomqtt is None: # pragma: no cover
raise ImportError(
"aiomqtt is required for Zigbee import. "
"Install it with: pip install aiomqtt"
)
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
response_payload: dict[str, Any] = {}
tls_context = _build_tls_context(tls_insecure) if tls else None
try:
async with aiomqtt.Client(
hostname=mqtt_host,
port=mqtt_port,
username=username,
password=password,
timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
) as client:
await client.subscribe(response_topic)
# Give the broker a brief window to register the subscription
# before we publish the request. Without this, brokers that
# race SUBACK with our PUBLISH may deliver the response before
# the subscription is active and we'd hang until timeout.
await asyncio.sleep(0.1)
await client.publish(
request_topic,
json.dumps({"type": "raw", "routes": False}),
)
async def _wait_for_response() -> None:
async for message in client.messages:
if str(message.topic) != response_topic:
continue
raw = message.payload
try:
payload_str = (
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
)
response_payload.update(json.loads(payload_str))
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(
f"Malformed networkmap response: {exc}"
) from exc
return
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
except aiomqtt.MqttError as exc:
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
except asyncio.TimeoutError as exc:
raise TimeoutError("Timed out waiting for networkmap response") from exc
if not response_payload:
raise ValueError("Empty networkmap response received")
return parse_networkmap(response_payload)
async def test_mqtt_connection(
mqtt_host: str,
mqtt_port: int,
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> bool:
"""Attempt a quick MQTT connection to verify broker reachability.
Returns True on success, raises ConnectionError on failure.
"""
if aiomqtt is None: # pragma: no cover
raise ImportError("aiomqtt is required")
tls_context = _build_tls_context(tls_insecure) if tls else None
try:
async with aiomqtt.Client(
hostname=mqtt_host,
port=mqtt_port,
username=username,
password=password,
timeout=_CONNECTION_TIMEOUT,
tls_context=tls_context,
):
return True
except aiomqtt.MqttError as exc:
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
except asyncio.TimeoutError as exc:
raise TimeoutError("Connection to broker timed out") from exc
-235
View File
@@ -1,235 +0,0 @@
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
"""
from __future__ import annotations
import logging
from typing import Any
from app.services.mqtt_common import request_response, test_connection
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
logger = logging.getLogger(__name__)
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
merge_zwave_properties = merge_zigbee_properties
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
"""Map a Z-Wave node's role flags to a homelable node type.
Controller → coordinator. Mains-powered / routing nodes → router.
Everything else (battery sensors, etc.) → end device.
"""
if raw.get("isControllerNode"):
return "zwave_coordinator"
if raw.get("isRouting"):
return "zwave_router"
return "zwave_enddevice"
def _role_label(node_type: str) -> str:
"""Human role string stored as ``device_subtype`` / ``device_type``."""
return {
"zwave_coordinator": "Controller",
"zwave_router": "Router",
"zwave_enddevice": "EndDevice",
}.get(node_type, "EndDevice")
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
node_id = raw.get("id")
if node_id is None:
return None
ieee = f"zwave-{home_id}-{node_id}"
node_type = _zwave_type_to_homelable(raw)
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
model = raw.get("productLabel") or raw.get("productDescription") or None
vendor = raw.get("manufacturer") or None
return {
"id": ieee,
"label": name,
"type": node_type,
"ieee_address": ieee,
"friendly_name": name,
"device_type": _role_label(node_type),
"node_id": node_id,
"model": model,
"vendor": vendor,
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
"parent_id": None,
"neighbors": raw.get("neighbors") or [],
}
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
"""Pick a home id for the network: prefer the controller's, else any node's."""
controller_home = None
for entry in raw_nodes:
if not isinstance(entry, dict):
continue
home = entry.get("homeId")
if home is None:
continue
if entry.get("isControllerNode"):
return str(home)
if controller_home is None:
controller_home = str(home)
return controller_home or "0"
def parse_zwave_nodes(
payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
Expected shape::
{"success": true, "result": [ {<node>}, ... ]}
Edges are a strict coordinator → router → end-device tree, derived from
each node's ``neighbors`` list (same approach as the Zigbee parser).
"""
if payload.get("success") is False:
raise ValueError("Z-Wave gateway reported failure")
result = payload.get("result")
if result is None:
result = []
if not isinstance(result, list):
raise ValueError("Malformed getNodes response: 'result' is not a list")
home_id = _resolve_home_id(result)
nodes_list: list[dict[str, Any]] = []
seen_ids: set[str] = set()
coordinator_id: str | None = None
# Map nodeId (int) → identity string, to translate neighbors → edges.
id_by_node_id: dict[Any, str] = {}
for entry in result:
if not isinstance(entry, dict):
continue
node = _node_from_zwave(entry, home_id)
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
id_by_node_id[node["node_id"]] = node["id"]
nodes_list.append(node)
if node["type"] == "zwave_coordinator":
coordinator_id = node["id"]
# Translate neighbor lists into candidate edges (only between known nodes).
raw_edges: list[dict[str, Any]] = []
for node in nodes_list:
src = node["id"]
for neighbor in node.get("neighbors") or []:
tgt = id_by_node_id.get(neighbor)
if tgt and tgt != src:
raw_edges.append({"source": src, "target": tgt})
# Build parent_id hierarchy: coordinator → routers → end devices.
if coordinator_id:
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
for node in nodes_list:
if node["type"] == "zwave_router":
node["parent_id"] = coordinator_id
elif node["type"] == "zwave_enddevice":
parent = _find_parent_router(node["id"], router_ids, raw_edges)
node["parent_id"] = parent or coordinator_id
# Final edges = strict parent → child tree (one edge per non-coordinator).
edges_list: list[dict[str, Any]] = [
{"source": node["parent_id"], "target": node["id"]}
for node in nodes_list
if node.get("parent_id")
]
# Drop transient helper keys before returning.
for node in nodes_list:
node.pop("neighbors", None)
node.pop("node_id", None)
return nodes_list, edges_list
def build_zwave_properties(
ieee: str | None,
vendor: str | None,
model: str | None,
) -> list[dict[str, Any]]:
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
Z-Wave has no LQI, so that row is omitted. New props default to
``visible=False`` — users opt in from the right panel.
"""
props: list[dict[str, Any]] = []
if ieee:
props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
if vendor:
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
if model:
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
return props
async def fetch_zwave_network(
mqtt_host: str,
mqtt_port: int,
prefix: str = "zwave",
gateway_name: str = "zwavejs2mqtt",
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Connect to the broker, request the Z-Wave node list, return (nodes, edges).
Raises:
TimeoutError: if the gateway does not respond in time.
ConnectionError: if the broker cannot be reached.
ValueError: if the response payload is malformed.
"""
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
payload = await request_response(
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
request_topic=request_topic,
response_topic=response_topic,
request_payload={"args": []},
username=username,
password=password,
tls=tls,
tls_insecure=tls_insecure,
)
return parse_zwave_nodes(payload)
async def test_zwave_connection(
mqtt_host: str,
mqtt_port: int,
username: str | None = None,
password: str | None = None,
tls: bool = False,
tls_insecure: bool = False,
) -> bool:
"""Quick MQTT reachability check for the Z-Wave broker."""
return await test_connection(
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
username=username,
password=password,
tls=tls,
tls_insecure=tls_insecure,
)
-1
View File
@@ -2,4 +2,3 @@
*.db-shm
*.db-wal
scan_config.json
homelab.db.*
+146
View File
@@ -0,0 +1,146 @@
[
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

-2
View File
@@ -25,8 +25,6 @@ addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["*/migrations/*", "*/tests/*"]
concurrency = ["thread"]
core = "sysmon"
[tool.coverage.report]
skip_empty = true
+5 -6
View File
@@ -7,20 +7,19 @@ alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
bcrypt==4.2.1
python-multipart==0.0.31
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.22
apscheduler==3.10.4
python-nmap==0.7.1
pyyaml==6.0.2
types-PyYAML==6.0.12.20240917
websockets==13.1
httpx==0.27.2
zeroconf==0.149.16
aiomqtt==2.3.0
# Dev
ruff==0.6.9
mypy==1.11.2
pytest==9.0.3
pytest-asyncio==1.3.0
pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==5.0.0
+5 -3
View File
@@ -1,11 +1,13 @@
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
"""Generate a bcrypt password hash for config.yml."""
import sys
import bcrypt
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if len(sys.argv) < 2:
print("Usage: python scripts/hash_password.py <password>")
sys.exit(1)
password = sys.argv[1]
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
print(pwd_context.hash(password))
+10 -5
View File
@@ -5,21 +5,23 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from httpx import ASGITransport, AsyncClient
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.security import hash_password
from app.db.database import Base, get_db
from app.main import app
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
@pytest.fixture(autouse=True, scope="session")
def test_credentials():
"""Configure test auth credentials directly on settings."""
from app.core.config import settings
settings.auth_username = "admin"
settings.auth_password_hash = hash_password("admin")
settings.auth_password_hash = _pwd_ctx.hash("admin")
@pytest.fixture
@@ -44,7 +46,10 @@ async def client(db_session: AsyncSession):
@pytest.fixture
async def headers(client: AsyncClient):
"""Authenticated Bearer headers for the default admin test user."""
def auth_headers(client):
"""Returns a coroutine that logs in and returns auth headers."""
async def _get():
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
return _get
-57
View File
@@ -1,57 +0,0 @@
"""
Tests for automatic DB backup before migrations.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
from pathlib import Path
from unittest.mock import patch
import pytest
from app.db.database import _backup_db
@pytest.fixture()
def tmp_db(tmp_path: Path):
db = tmp_path / "homelab.db"
db.write_bytes(b"SQLite placeholder")
return db
def test_backup_created_when_db_exists(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
assert backup.exists()
assert backup.read_bytes() == b"SQLite placeholder"
def test_backup_skipped_when_db_missing(tmp_path: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
_backup_db()
assert not any(tmp_path.glob("*.back-*"))
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
backup.write_bytes(b"original backup")
_backup_db()
assert backup.read_bytes() == b"original backup"
def test_backup_version_in_filename(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "2.0"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
@@ -1,156 +0,0 @@
"""Backward-compatibility tests for the legacy → multi-design migration.
Simulates a database created by a pre-"designs" version of the app and asserts
that running init_db() adopts all existing nodes/edges/canvas into a single
default "Network Topology" design with no data loss. The rest of the test suite
builds the *current* schema via create_all and never exercises this upgrade
path, so this file guards real users upgrading in place.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
import app.db.database as database
@pytest.fixture
def legacy_engine(tmp_path, monkeypatch):
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
db_path = tmp_path / "legacy.db"
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
monkeypatch.setattr(database, "engine", engine)
return db_path, engine
async def _build_legacy_schema(engine) -> None:
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
async with engine.begin() as conn:
await conn.exec_driver_sql(
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
)
await conn.exec_driver_sql(
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
)
await conn.exec_driver_sql(
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
"custom_style JSON, saved_at DATETIME)"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
"VALUES ('n1','server','Old Server','online','[]',10,20)"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
)
await conn.exec_driver_sql(
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
)
await conn.exec_driver_sql(
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
)
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
# Exactly one seeded default design.
designs = (await conn.exec_driver_sql(
"SELECT id, name, design_type, icon FROM designs"
)).fetchall()
assert len(designs) == 1
did, name, dtype, icon = designs[0]
assert name == "Network Topology"
assert dtype == "network"
assert icon == "dashboard"
# Every legacy node adopted into the default design, data preserved.
nodes = (await conn.exec_driver_sql(
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
)).fetchall()
assert [(n[0], n[1], n[2]) for n in nodes] == [
("n1", "Old Server", "online"),
("n2", "Old Router", "offline"),
]
assert all(n[3] == did for n in nodes)
# Legacy edge adopted too.
edge = (await conn.exec_driver_sql(
"SELECT design_id FROM edges WHERE id='e1'"
)).fetchone()
assert edge[0] == did
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
# default design and the viewport survives.
cs = (await conn.exec_driver_sql(
"SELECT design_id, viewport FROM canvas_state"
)).fetchall()
assert len(cs) == 1
assert cs[0][0] == did
assert "zoom" in (cs[0][1] or "")
finally:
await check.dispose()
await engine.dispose()
async def test_legacy_nodes_gain_last_scan_column(legacy_engine):
"""A legacy nodes table (no last_scan) gains the column after init_db."""
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
cols = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
assert "last_scan" in {c[1] for c in cols}
# Existing rows backfill to NULL (never scanned yet).
last_scan = (await conn.exec_driver_sql(
"SELECT last_scan FROM nodes WHERE id='n1'"
)).fetchone()
assert last_scan[0] is None
finally:
await check.dispose()
await engine.dispose()
async def test_migration_is_idempotent(legacy_engine):
"""Running init_db twice must not duplicate the design or drop any data."""
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
await database.init_db() # second boot — should be a no-op
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
assert len(designs) == 1
did = designs[0][0]
nodes = (await conn.exec_driver_sql(
"SELECT design_id FROM nodes"
)).fetchall()
assert len(nodes) == 2
assert all(n[0] == did for n in nodes)
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
assert len(cs) == 1
assert cs[0][0] == did
finally:
await check.dispose()
await engine.dispose()
-176
View File
@@ -1,176 +0,0 @@
"""
Tests for the hardware properties migration logic.
We test the migration function directly against an in-memory SQLite database
so we can set up legacy rows (with hardware columns, NULL properties) and
verify the migration produces the expected properties JSON.
"""
import json
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
async def _setup_legacy_table(conn):
"""Create a minimal nodes table that mimics the pre-migration schema."""
await conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'generic',
label TEXT NOT NULL DEFAULT '',
cpu_model TEXT,
cpu_count INTEGER,
ram_gb REAL,
disk_gb REAL,
show_hardware BOOLEAN NOT NULL DEFAULT 0,
properties JSON
)
""")
async def _run_migration(conn):
"""Run only the properties migration portion (extracted from init_db)."""
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for row in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
props = []
visible = bool(show_hardware)
if cpu_model:
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
if cpu_count is not None:
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
if ram_gb is not None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(json.dumps(props), node_id),
)
async def _get_properties(conn, node_id: str) -> list:
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
raw = rows.fetchone()[0]
return json.loads(raw) if raw else []
@pytest.mark.asyncio
async def test_migration_full_hardware():
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
"VALUES (?, ?, ?, ?, ?, ?)",
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-1")
assert len(props) == 4
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_partial_hardware():
"""Node with only cpu_model and ram_gb → 2 property entries."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
("node-2", "Ryzen 5 5600", 16.0, 0),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-2")
assert len(props) == 2
assert props[0]["key"] == "CPU Model"
assert props[0]["visible"] is False
assert props[1]["key"] == "RAM"
assert props[1]["icon"] == "MemoryStick"
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_no_hardware():
"""Node with no hardware fields → empty properties array."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id) VALUES (?)",
("node-3",),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-3")
assert props == []
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_idempotent():
"""Running migration twice does not duplicate properties."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
("node-4", "Core i5", 1),
)
await _run_migration(conn)
await _run_migration(conn) # second pass — node already has properties, should be skipped
props = await _get_properties(conn, "node-4")
assert len(props) == 1
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_show_hardware_false_sets_visible_false():
"""show_hardware=0 means all migrated properties have visible=False."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
("node-5", "ARM Cortex-A72", 4.0, 0),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-5")
assert all(p["visible"] is False for p in props)
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_already_migrated_node_not_touched():
"""Node that already has properties is skipped — existing properties preserved."""
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-6")
assert props == existing
await engine.dispose()
View File
-84
View File
@@ -1,84 +0,0 @@
"""Fixtures shared across the scan test modules."""
import uuid
import pytest
from app.db.models import PendingDevice
@pytest.fixture
async def pending_device(db_session):
import uuid
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.100",
mac="aa:bb:cc:dd:ee:ff",
hostname="my-server",
os="Linux",
services=[{"port": 22, "name": "ssh"}],
suggested_type="server",
status="pending",
)
db_session.add(device)
await db_session.commit()
await db_session.refresh(device)
return device
@pytest.fixture
async def mem_db():
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.database import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
yield factory
await engine.dispose()
@pytest.fixture
async def two_pending_devices(db_session):
devices = []
for i in range(2):
d = PendingDevice(
id=str(uuid.uuid4()),
ip=f"192.168.1.{10 + i}",
mac=None,
hostname=f"host-{i}",
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(d)
devices.append(d)
await db_session.commit()
for d in devices:
await db_session.refresh(d)
return devices
@pytest.fixture
async def zigbee_pending_device(db_session):
device = PendingDevice(
id=str(uuid.uuid4()),
ip=None,
mac=None,
hostname=None,
friendly_name="bulb_1",
services=[],
suggested_type="zigbee_enddevice",
device_subtype="EndDevice",
ieee_address="0xABCDEF",
vendor="IKEA",
model="TRADFRI",
lqi=180,
status="pending",
discovery_source="zigbee",
)
db_session.add(device)
await db_session.commit()
await db_session.refresh(device)
return device
-52
View File
@@ -1,52 +0,0 @@
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
import uuid
from app.db.models import Design, Node, PendingDevice
async def _add_design(db_session, name: str) -> str:
design = Design(id=str(uuid.uuid4()), name=name)
db_session.add(design)
await db_session.commit()
return design.id
def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
return Node(
id=str(uuid.uuid4()), label="n", type="server", status="online",
ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
design_id=design_id,
)
async def _seed_zigbee_pending_pair(db_session):
"""Create a coordinator Node + a pending device + a link between them."""
from app.db.models import Node, PendingDeviceLink
coord = Node(
label="Coordinator",
type="zigbee_coordinator",
status="unknown",
ieee_address="0xCOORD",
)
db_session.add(coord)
pending = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="pending",
discovery_source="zigbee",
)
db_session.add(pending)
db_session.add(
PendingDeviceLink(
source_ieee="0xCOORD",
target_ieee="0xR1",
discovery_source="zigbee",
)
)
await db_session.commit()
return coord, pending
-723
View File
@@ -1,723 +0,0 @@
"""Approve / hide / restore / ignore / bulk device flows and conflict handling."""
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Design, Node, PendingDevice
from tests.scan.helpers import _add_design, _node
@pytest.mark.asyncio
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
# A node with no design_id is not "on a canvas".
db_session.add(_node(None, ip="192.168.1.100"))
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.json()[0]["canvas_count"] == 0
@pytest.mark.asyncio
async def test_approve_device(client: AsyncClient, headers, pending_device):
node_payload = {
"label": "My Server",
"type": "server",
"ip": "192.168.1.100",
"hostname": "my-server",
"status": "unknown",
"services": [],
}
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json=node_payload,
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert "node_id" in data
# Approved devices stay in the inventory (status != "hidden") so they keep
# showing with an "In N canvas" badge — they are no longer dropped.
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
inventory = pending_res.json()
assert len(inventory) == 1
assert inventory[0]["id"] == pending_device.id
assert inventory[0]["status"] == "approved"
@pytest.mark.asyncio
async def test_approve_device_conflicts_on_existing_ieee_same_design(
client: AsyncClient, headers, db_session
):
"""Approving a device whose IEEE is already on the target design prompts the
user (409) instead of silently merging/replacing same UX as ip/mac."""
design = Design(name="d1")
db_session.add(design)
await db_session.flush()
existing = Node(
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
services=[], design_id=design.id,
)
db_session.add(existing)
device = PendingDevice(
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
status="pending", discovery_source="zigbee",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{device.id}/approve",
json={
"label": "sensor", "type": "zigbee_enddevice",
"status": "online", "services": [], "design_id": design.id,
},
headers=headers,
)
assert res.status_code == 409
detail = res.json()["detail"]
assert detail["duplicate"] is True
assert detail["existing_node_id"] == existing.id
assert detail["match"] == "ieee"
assert detail["value"] == "0xZZZ"
# No second node created; device stays pending until the user decides.
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
).scalars().all()
assert len(nodes) == 1
@pytest.mark.asyncio
async def test_approve_device_force_creates_duplicate_ieee(
client: AsyncClient, headers, db_session
):
"""force=True lets the user place a second card for the same IEEE."""
design = Design(name="d1")
db_session.add(design)
await db_session.flush()
db_session.add(Node(
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
services=[], design_id=design.id,
))
device = PendingDevice(
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
status="pending", discovery_source="zigbee",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{device.id}/approve",
json={
"label": "sensor", "type": "zigbee_enddevice", "status": "online",
"services": [], "design_id": design.id, "force": True,
},
headers=headers,
)
assert res.status_code == 200
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
).scalars().all()
assert len(nodes) == 2
@pytest.mark.asyncio
async def test_approve_device_conflicts_on_existing_ip(
client: AsyncClient, headers, db_session, pending_device
):
"""An ordinary host whose ip already sits on the target design is NOT
silently duplicated: the approve returns 409 with the existing node so the
UI can ask the user."""
design = await _add_design(db_session, "Home")
existing = _node(design, ip="192.168.1.100")
db_session.add(existing)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
"status": "unknown", "services": [], "design_id": design},
headers=headers,
)
assert res.status_code == 409
detail = res.json()["detail"]
assert detail["duplicate"] is True
assert detail["existing_node_id"] == existing.id
assert detail["match"] == "ip"
assert detail["value"] == "192.168.1.100"
# No node created, device left pending (user hasn't decided yet).
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
assert len(nodes) == 1
await db_session.refresh(pending_device)
assert pending_device.status == "pending"
@pytest.mark.asyncio
async def test_approve_device_conflicts_on_existing_mac(
client: AsyncClient, headers, db_session, pending_device
):
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
design = await _add_design(db_session, "Home")
existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
db_session.add(existing)
await db_session.commit()
# pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "dup", "type": "server", "ip": "192.168.1.55",
"mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [],
"design_id": design},
headers=headers,
)
assert res.status_code == 409
assert res.json()["detail"]["match"] == "mac"
@pytest.mark.asyncio
async def test_approve_device_conflicts_on_ip_in_comma_list(
client: AsyncClient, headers, db_session, pending_device
):
"""The existing node's ip holds an IPv6 before the IPv4 the device scanned
as. Exact-string matching missed it (issue #258); per-token matching catches
the duplicate."""
design = await _add_design(db_session, "Home")
existing = _node(design, ip="fe80::1, 192.168.1.100")
db_session.add(existing)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
"status": "unknown", "services": [], "design_id": design},
headers=headers,
)
assert res.status_code == 409
detail = res.json()["detail"]
assert detail["existing_node_id"] == existing.id
assert detail["match"] == "ip"
assert detail["value"] == "192.168.1.100"
@pytest.mark.asyncio
async def test_approve_device_no_conflict_on_ip_substring(
client: AsyncClient, headers, db_session, pending_device
):
"""The ip guard must match whole addresses, not substrings: a node at
10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
design = await _add_design(db_session, "Home")
db_session.add(_node(design, ip="10.0.0.40"))
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "new", "type": "server", "ip": "10.0.0.4",
"mac": None, "status": "unknown", "services": [], "design_id": design},
headers=headers,
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_approve_device_force_creates_duplicate(
client: AsyncClient, headers, db_session, pending_device
):
"""force=True (user confirmed) bypasses the guard and creates the node."""
design = await _add_design(db_session, "Home")
db_session.add(_node(design, ip="192.168.1.100"))
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
"status": "unknown", "services": [], "design_id": design, "force": True},
headers=headers,
)
assert res.status_code == 200
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
assert len(nodes) == 2 # duplicate deliberately created
@pytest.mark.asyncio
async def test_approve_device_allows_same_ip_on_other_design(
client: AsyncClient, headers, db_session, pending_device
):
"""The guard is per-design: the same host on a different canvas is fine."""
other = await _add_design(db_session, "Lab")
target = await _add_design(db_session, "Home")
db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "ok", "type": "server", "ip": "192.168.1.100",
"status": "unknown", "services": [], "design_id": target},
headers=headers,
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_approve_device_places_already_approved_on_another_design(
client: AsyncClient, headers, db_session
):
"""A device already approved on ANOTHER canvas (global status="approved")
must still be placeable on a new design status is global, canvas
membership is per-design (mirrors bulk_approve)."""
other = await _add_design(db_session, "Other")
target = await _add_design(db_session, "Network Topology")
# Device is on `other` already (its global status is "approved").
db_session.add(_node(other, ieee="0x00158d0005292b83"))
device = PendingDevice(
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
suggested_type="zigbee_enddevice", status="approved",
discovery_source="zigbee",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{device.id}/approve",
json={"label": "sensor", "type": "zigbee_enddevice", "status": "online",
"services": [], "design_id": target},
headers=headers,
)
assert res.status_code == 200
# A node now exists on the target design too (one per canvas).
nodes = (
await db_session.execute(
select(Node).where(Node.ieee_address == "0x00158d0005292b83")
)
).scalars().all()
assert {n.design_id for n in nodes} == {other, target}
@pytest.mark.asyncio
async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device):
"""A user-hidden device is not approvable via this endpoint."""
pending_device.status = "hidden"
db_session.add(pending_device)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "x", "type": "server", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_approve_nonexistent_device(client: AsyncClient, headers):
node_payload = {
"label": "Ghost",
"type": "generic",
"ip": "10.0.0.1",
"status": "unknown",
"services": [],
}
res = await client.post(
"/api/v1/scan/pending/nonexistent-id/approve",
json=node_payload,
headers=headers,
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_hide_device(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
assert res.status_code == 200
assert res.json()["hidden"] is True
# Should no longer appear in pending
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
# Should appear in hidden
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 1
@pytest.mark.asyncio
async def test_restore_device(client: AsyncClient, headers, pending_device):
# Hide first
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
# Restore
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
assert res.status_code == 200
assert res.json()["restored"] is True
# Now back in pending, gone from hidden
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert len(pending_res.json()) == 1
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert hidden_res.json() == []
@pytest.mark.asyncio
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
# Hide
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
res = await client.post(
"/api/v1/scan/pending/bulk-restore",
headers=headers,
json={"device_ids": [pending_device.id]},
)
assert res.status_code == 200
assert res.json()["restored"] == 1
assert res.json()["skipped"] == 0
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert len(pending_res.json()) == 1
@pytest.mark.asyncio
async def test_ignore_device(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
assert res.status_code == 200
assert res.json()["ignored"] is True
# Device should be gone from both pending and hidden
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert hidden_res.json() == []
@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 2
assert len(data["node_ids"]) == 2
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
assert len(data["device_ids"]) == 2
assert data["skipped"] == 0
# Approved devices stay in the inventory, now marked "approved".
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
inventory = pending_res.json()
assert len(inventory) == 2
assert all(d["status"] == "approved" for d in inventory)
@pytest.mark.asyncio
async def test_bulk_approve_places_already_approved_device_on_another_design(
client: AsyncClient, headers, db_session, two_pending_devices
):
"""Regression: a device already approved (status='approved', e.g. placed on
another canvas) must still get a node on the design being approved onto.
Previously bulk-approve filtered status=='pending', so selecting an
already-approved device created no node the user saw fewer nodes than
they selected."""
ids = [d.id for d in two_pending_devices]
design_a = await _add_design(db_session, "Canvas A")
design_b = await _add_design(db_session, "Canvas B")
# Approve both onto design A.
res_a = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design_a},
headers=headers,
)
assert res_a.json()["approved"] == 2
# Re-approve the same (now status='approved') devices onto design B.
res_b = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design_b},
headers=headers,
)
data_b = res_b.json()
assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
assert data_b["skipped"] == 0
# Two nodes now exist on each design.
from app.db.models import Node as NodeModel
nodes_b = (
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
).scalars().all()
assert len(nodes_b) == 2
@pytest.mark.asyncio
async def test_bulk_approve_skips_device_already_on_target_design(
client: AsyncClient, headers, db_session, two_pending_devices
):
"""A device already on the target canvas (same ip) is not placed twice."""
ids = [d.id for d in two_pending_devices]
design = await _add_design(db_session, "Canvas")
# First device already sits on the canvas (matched by ip).
db_session.add(_node(design, ip="192.168.1.10"))
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design},
headers=headers,
)
data = res.json()
assert data["approved"] == 1 # only the second device (192.168.1.11)
assert data["skipped"] == 1
from app.db.models import Node as NodeModel
nodes = (
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
).scalars().all()
# The pre-existing node plus the one newly approved — no duplicate for .10.
assert len(nodes) == 2
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
@pytest.mark.asyncio
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
client: AsyncClient, headers, db_session, two_pending_devices
):
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
as the plain IPv4 is still recognised as already placed (issue #258)."""
ids = [d.id for d in two_pending_devices]
design = await _add_design(db_session, "Canvas")
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design},
headers=headers,
)
data = res.json()
assert data["approved"] == 1 # only the second device (192.168.1.11)
assert data["skipped"] == 1
assert data["skipped_devices"][0]["value"] == "192.168.1.10"
@pytest.mark.asyncio
async def test_bulk_approve_reports_skipped_devices(
client: AsyncClient, headers, db_session, two_pending_devices
):
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
(with the existing node id) instead of silently dropping it."""
ids = [d.id for d in two_pending_devices]
design = await _add_design(db_session, "Canvas")
existing = _node(design, ip="192.168.1.10")
db_session.add(existing)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": ids, "design_id": design},
headers=headers,
)
data = res.json()
assert data["approved"] == 1
skipped = data["skipped_devices"]
assert len(skipped) == 1
entry = skipped[0]
assert entry["match"] == "ip"
assert entry["value"] == "192.168.1.10"
assert entry["existing_node_id"] == existing.id
assert entry["device_id"] in ids
@pytest.mark.asyncio
async def test_approve_device_copies_mac_to_node_and_properties(
client: AsyncClient, headers, pending_device, db_session
):
"""Approving a scanned device must carry its MAC onto the node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
# Payload intentionally omits mac — it must come from the pending device.
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
assert node.mac == "aa:bb:cc:dd:ee:ff"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
@pytest.mark.asyncio
async def test_bulk_approve_copies_mac_to_node_and_properties(
client: AsyncClient, headers, db_session
):
"""Bulk approve must also propagate the scanned MAC to node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.55",
mac="11:22:33:44:55:66",
hostname="host-mac",
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [device.id]},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
).scalar_one()
assert node.mac == "11:22:33:44:55:66"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
]
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
for n in nodes:
if n.ip:
assert n.check_method == "ping", f"node {n.id} created without check_method"
@pytest.mark.asyncio
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 200
node = (await db_session.execute(select(NodeModel))).scalars().first()
assert node is not None
assert node.check_method == "ping"
@pytest.mark.asyncio
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Approve first device individually first
await client.post(
f"/api/v1/scan/pending/{ids[0]}/approve",
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
headers=headers,
)
# Bulk approve both — first one is already approved (not pending), should be skipped
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 2
assert data["skipped"] == 0
# Should appear in hidden list
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 2
@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Hide first device individually first
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
# Bulk hide both — first is already hidden (not pending anymore)
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_bulk_approve_targets_requested_design(client, headers, db_session):
"""bulk-approve must place nodes on the design_id sent by the UI, not the
first design otherwise approved devices land on the wrong canvas."""
first = await _add_design(db_session, "Default") # first design (fallback)
active = await _add_design(db_session, "zwave") # the design the user is on
dev = PendingDevice(
id=str(uuid.uuid4()),
ieee_address="zwave-H-2",
friendly_name="Living Room Plug",
suggested_type="zwave_router",
device_subtype="Router",
vendor="Aeotec",
model="ZW096",
status="pending",
discovery_source="zwave",
)
db_session.add(dev)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [dev.id], "design_id": active},
headers=headers,
)
assert res.status_code == 200
assert res.json()["approved"] == 1
node = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
).scalar_one()
assert node.design_id == active
assert node.design_id != first
# Z-Wave device → online + Z-Wave property rows, no ICMP check.
assert node.status == "online"
assert node.check_method == "none"
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
-336
View File
@@ -1,336 +0,0 @@
"""MAC-property builders and Zigbee/Z-Wave property population on approve."""
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Node, PendingDevice
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
@pytest.mark.asyncio
async def test_approve_zigbee_device_populates_properties(
client: AsyncClient, headers, zigbee_pending_device, db_session
):
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
payload = {
"label": "bulb_1",
"type": "zigbee_enddevice",
"status": "online",
"services": [],
"check_method": "none",
}
res = await client.post(
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
json=payload,
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
).scalar_one()
keys = {p["key"]: p["value"] for p in node.properties}
assert keys == {
"IEEE": "0xABCDEF",
"Vendor": "IKEA",
"Model": "TRADFRI",
"LQI": "180",
}
@pytest.mark.asyncio
async def test_bulk_approve_zigbee_populates_properties(
client: AsyncClient, headers, zigbee_pending_device, db_session
):
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [zigbee_pending_device.id]},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
).scalar_one()
keys = {p["key"]: p["value"] for p in node.properties}
assert keys["IEEE"] == "0xABCDEF"
assert keys["Vendor"] == "IKEA"
assert keys["Model"] == "TRADFRI"
assert keys["LQI"] == "180"
assert node.check_method == "none"
def test_build_mac_property_returns_hidden_row():
from app.api.routes.scan import build_mac_property
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
def test_build_mac_property_empty_when_no_mac():
from app.api.routes.scan import build_mac_property
assert build_mac_property(None) == []
assert build_mac_property("") == []
def test_merge_mac_property_appends_when_absent():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
# Existing prop preserved untouched.
assert existing[0] in merged
def test_merge_mac_property_idempotent_and_preserves_visibility():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
# No duplicate MAC row; user's visible=True choice kept.
macs = [p for p in merged if p["key"] == "MAC"]
assert len(macs) == 1
assert macs[0]["visible"] is True
def test_merge_mac_property_noop_without_mac():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
assert merge_mac_property(existing, None) == existing
@pytest.mark.asyncio
async def test_approve_device_does_not_duplicate_mac_property(
client: AsyncClient, headers, pending_device, db_session
):
"""If the approve payload already carries a MAC prop, don't add a second one."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={
"label": "My Server",
"type": "server",
"ip": "192.168.1.100",
"status": "unknown",
"services": [],
"properties": [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
],
},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert len(mac_props) == 1
# User's visibility choice is preserved.
assert mac_props[0]["visible"] is True
@pytest.mark.asyncio
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
client: AsyncClient, headers, db_session
):
from sqlalchemy import select
from app.db.models import Edge
coord, pending = await _seed_zigbee_pending_pair(db_session)
res = await client.post(
f"/api/v1/scan/pending/{pending.id}/approve",
json={
"label": "router_1",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert data["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
assert edges[0].source == coord.id
assert edges[0].target == data["node_id"]
assert edges[0].source_handle == "bottom"
# Bare side name (canonical stored form); renders at the top like before.
assert edges[0].target_handle == "top"
assert edges[0].type == "iot"
@pytest.mark.asyncio
async def test_approve_zigbee_skips_duplicate_edge(
client: AsyncClient, headers, db_session
):
"""Re-running the resolution does not create a second edge for the same pair."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
coord, pending = await _seed_zigbee_pending_pair(db_session)
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
# Simulate a second pending row + link between same coord and a new device,
# but keep an existing edge in place to verify dedupe also handles
# the swapped-direction case.
new_pending = PendingDevice(
ieee_address="0xR1B",
friendly_name="r1b",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
db_session.add(new_pending)
db_session.add(
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
)
assert res.json()["edges_created"] == 1 # only the new pair
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 2 # original + new, no duplicate
@pytest.mark.asyncio
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
client: AsyncClient, headers, db_session
):
"""Both endpoints pending → no edge yet, link row preserved for later."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{a.id}/approve",
json={
"label": "a",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
assert res.json()["edges_created"] == 0
edges = (await db_session.execute(select(Edge))).scalars().all()
assert edges == []
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(links) == 1 # preserved for later resolution
@pytest.mark.asyncio
async def test_approve_zigbee_resolves_link_after_second_approval(
client: AsyncClient, headers, db_session
):
"""First approval keeps link (other endpoint pending); second approval
creates the edge. The link row is retained afterwards so the same pair can
be re-approved onto another canvas it's topology, wiped only on reimport."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
assert res.json()["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(links) == 1 # retained for re-approval onto other canvases
@pytest.mark.asyncio
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
active = await _add_design(db_session, "zwave")
dev = PendingDevice(
id=str(uuid.uuid4()),
ieee_address="zwave-H-9",
friendly_name="Door Sensor",
suggested_type="zwave_enddevice",
vendor="Aeotec",
model="ZW120",
status="pending",
discovery_source="zwave",
)
db_session.add(dev)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{dev.id}/approve",
json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
).scalar_one()
assert node.design_id == active
assert node.status == "online"
assert node.check_method == "none"
assert any(p["key"] == "Z-Wave ID" for p in node.properties)
-262
View File
@@ -1,262 +0,0 @@
"""Scan API routes: trigger, pending list, canvas-count correlation, timestamps, config."""
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from app.db.models import PendingDevice
from tests.scan.helpers import _add_design, _node
@pytest.mark.asyncio
async def test_trigger_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/trigger")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
with (
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
patch("app.api.routes.scan.settings") as mock_settings,
):
mock_settings.scanner_ranges = ["192.168.1.0/24"]
res = await client.post("/api/v1/scan/trigger", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["status"] == "running"
assert data["ranges"] == ["192.168.1.0/24"]
assert "id" in data
@pytest.mark.asyncio
async def test_list_pending_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
assert res.json() == []
@pytest.mark.asyncio
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["hostname"] == "my-server"
# No matching node → not on any canvas.
assert data[0]["canvas_count"] == 0
@pytest.mark.asyncio
async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session, pending_device):
# Node.ip holds several comma-separated addresses (IPv6 added first). The
# device scanned as the plain IPv4 must still correlate (issue #258).
d1 = await _add_design(db_session, "Home")
db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert data[0]["canvas_count"] == 1
@pytest.mark.asyncio
async def test_canvas_count_correlates_by_mac(client, headers, db_session, pending_device):
# Node's ip differs entirely (user edited it) but the MAC still matches:
# the device is on the canvas (issue #258, MAC is the stable identifier).
d1 = await _add_design(db_session, "Home")
db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert data[0]["canvas_count"] == 1
@pytest.mark.asyncio
async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
# Same IP placed on two different canvases → canvas_count == 2.
d1 = await _add_design(db_session, "Home")
d2 = await _add_design(db_session, "Lab")
db_session.add(_node(d1, ip="192.168.1.100"))
db_session.add(_node(d2, ip="192.168.1.100"))
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
data = res.json()
assert len(data) == 1
assert data[0]["canvas_count"] == 2
@pytest.mark.asyncio
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
device = PendingDevice(
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
suggested_type="zigbee_enddevice", services=[], status="pending",
)
db_session.add(device)
d1 = await _add_design(db_session, "Zigbee")
db_session.add(_node(d1, ieee="0x00124b001"))
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
by_id = {d["id"]: d for d in res.json()}
assert by_id[device.id]["canvas_count"] == 1
@pytest.mark.asyncio
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
# No matching canvas node → node_* timestamps are all null; the device still
# carries its own discovered_at for the "Discovered" fallback on the tile.
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["discovered_at"] is not None
assert data["node_created_at"] is None
assert data["node_last_scan"] is None
assert data["node_last_modified"] is None
assert data["node_last_seen"] is None
@pytest.mark.asyncio
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
d1 = await _add_design(db_session, "Home")
node = _node(d1, ip="192.168.1.100")
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
db_session.add(node)
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["node_created_at"] is not None # defaulted on insert
assert data["node_last_modified"] is not None # updated_at defaulted on insert
assert data["node_last_scan"].startswith("2026-06-01")
assert data["node_last_seen"].startswith("2026-06-25")
@pytest.mark.asyncio
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
# Two canvas nodes share the device IP: created_at takes the OLDEST,
# last_scan takes the NEWEST.
d1 = await _add_design(db_session, "Home")
d2 = await _add_design(db_session, "Lab")
older = _node(d1, ip="192.168.1.100")
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
newer = _node(d2, ip="192.168.1.100")
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
db_session.add_all([older, newer])
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["node_created_at"].startswith("2026-01-01") # oldest
assert data["node_last_scan"].startswith("2026-06-01") # newest
@pytest.mark.asyncio
async def test_resolve_deep_scan_falls_back_to_settings():
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
with patch("app.api.routes.scan.settings") as mock_settings:
mock_settings.scanner_http_ranges = ["7000-7100"]
mock_settings.scanner_http_probe_enabled = True
mock_settings.scanner_http_verify_tls = False
# Empty payload → all values come from settings defaults
ds = _resolve_deep_scan(TriggerScanRequest())
assert ds.http_ranges == ["7000-7100"]
assert ds.http_probe_enabled is True
assert ds.verify_tls is False
@pytest.mark.asyncio
async def test_resolve_deep_scan_override_wins():
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
with patch("app.api.routes.scan.settings") as mock_settings:
mock_settings.scanner_http_ranges = []
mock_settings.scanner_http_probe_enabled = False
mock_settings.scanner_http_verify_tls = False
ds = _resolve_deep_scan(
TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
)
assert ds.http_ranges == ["9000"]
assert ds.http_probe_enabled is True
assert ds.verify_tls is True
@pytest.mark.asyncio
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
captured = {}
async def fake_bg(run_id, ranges, deep_scan):
captured["deep_scan"] = deep_scan
with (
patch("app.api.routes.scan._background_scan", new=fake_bg),
patch("app.api.routes.scan.settings") as mock_settings,
):
mock_settings.scanner_ranges = ["192.168.1.0/24"]
mock_settings.scanner_http_ranges = []
mock_settings.scanner_http_probe_enabled = False
mock_settings.scanner_http_verify_tls = False
res = await client.post(
"/api/v1/scan/trigger",
json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
headers=headers,
)
assert res.status_code == 200
assert captured["deep_scan"].http_probe_enabled is True
assert captured["deep_scan"].http_ranges == ["8000-8100"]
@pytest.mark.asyncio
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
with patch("app.api.routes.scan.settings") as mock_settings:
mock_settings.scanner_ranges = ["192.168.1.0/24"]
res = await client.post(
"/api/v1/scan/trigger",
json={"http_ranges": ["70000-80000"]},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
with patch("app.api.routes.scan.settings") as mock_settings:
mock_settings.scanner_ranges = ["192.168.1.0/24"]
mock_settings.scanner_http_ranges = ["8000-8100"]
mock_settings.scanner_http_probe_enabled = True
mock_settings.scanner_http_verify_tls = False
res = await client.get("/api/v1/scan/config", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["http_ranges"] == ["8000-8100"]
assert data["http_probe_enabled"] is True
@pytest.mark.asyncio
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
saved = {}
with patch("app.api.routes.scan.settings") as mock_settings:
mock_settings.scanner_ranges = ["192.168.1.0/24"]
mock_settings.scanner_http_ranges = []
mock_settings.scanner_http_probe_enabled = False
mock_settings.scanner_http_verify_tls = False
mock_settings.save_overrides = lambda: saved.update(
http_ranges=mock_settings.scanner_http_ranges,
probe=mock_settings.scanner_http_probe_enabled,
)
res = await client.post(
"/api/v1/scan/config",
json={
"ranges": ["192.168.1.0/24"],
"http_ranges": ["9000-9100"],
"http_probe_enabled": True,
"verify_tls": True,
},
headers=headers,
)
assert res.status_code == 200
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
-431
View File
@@ -1,431 +0,0 @@
"""run_scan service persistence, _background_scan lifecycle, stop/cancel."""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@pytest.mark.asyncio
async def test_background_scan_marks_run_failed_on_exception(mem_db):
"""If run_scan() raises, the ScanRun must transition running → failed and the
session rollback path must execute without a follow-on exception."""
from app.api.routes.scan import _background_scan
async with mem_db() as session:
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
session.add(run)
await session.commit()
run_id = run.id
with (
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch(
"app.api.routes.scan.run_scan",
new_callable=AsyncMock,
side_effect=RuntimeError("boom"),
),
):
await _background_scan(run_id, ["10.0.0.0/24"])
async with mem_db() as session:
refreshed = await session.get(ScanRun, run_id)
assert refreshed is not None
assert refreshed.status == "failed"
@pytest.mark.asyncio
async def test_background_scan_leaves_non_running_status_alone(mem_db):
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
must NOT overwrite that terminal status with 'failed'."""
from app.api.routes.scan import _background_scan
async with mem_db() as session:
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
session.add(run)
await session.commit()
run_id = run.id
with (
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch(
"app.api.routes.scan.run_scan",
new_callable=AsyncMock,
side_effect=RuntimeError("boom"),
),
):
await _background_scan(run_id, ["10.0.0.0/24"])
async with mem_db() as session:
refreshed = await session.get(ScanRun, run_id)
assert refreshed is not None
assert refreshed.status == "cancelled"
@pytest.mark.asyncio
async def test_background_scan_success_path_invokes_run_scan(mem_db):
from app.api.routes.scan import _background_scan
async with mem_db() as session:
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
session.add(run)
await session.commit()
run_id = run.id
with (
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
):
from app.services.scanner import DeepScanOptions
await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
mock_run_scan.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_runs_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/runs", headers=headers)
assert res.status_code == 200
assert res.json() == []
# --- run_scan: re-scan updates existing pending devices ---
MOCK_HOST = {
"ip": "192.168.1.50",
"mac": "aa:bb:cc:dd:ee:ff",
"hostname": "myhost.lan",
"os": "Linux",
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
}
@pytest.mark.asyncio
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
assert device.suggested_type == "server"
@pytest.mark.asyncio
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
in the inventory and are surfaced with an "In N canvas" badge."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
stale = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(node)
db_session.add(stale)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is not None
@pytest.mark.asyncio
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
"""A scanned IP that already exists as a canvas Node still produces a pending
device (no longer suppressed)."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
db_session.add(node)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
@pytest.mark.asyncio
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
"""Re-scanning an already-approved device updates its row in place instead of
spawning a fresh pending duplicate, and keeps it approved."""
approved = PendingDevice(
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
os=None, services=[], suggested_type="server", status="approved",
)
db_session.add(approved)
run_id = str(uuid.uuid4())
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
rows = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)).scalars().all()
assert len(rows) == 1
assert rows[0].status == "approved"
assert rows[0].hostname == "myhost.lan" # refreshed from the scan
@pytest.mark.asyncio
async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
row at scan start, even if the device is not re-discovered."""
for status in ("approved", "pending", "pending"):
db_session.add(PendingDevice(
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
os=None, services=[], suggested_type="server", status=status,
))
run_id = str(uuid.uuid4())
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
rows = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
)).scalars().all()
assert len(rows) == 1
assert rows[0].status == "approved" # approved row is the one kept
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
hidden = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="hidden",
)
db_session.add(hidden)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(
PendingDevice.ip == "192.168.1.50",
PendingDevice.status == "pending",
)
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_stop_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/fake-id/stop")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_stop_scan_not_found(client: AsyncClient, headers):
import uuid as _uuid
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 200
assert res.json() == {"stopping": True}
# run_id added to cancel set
assert run.id in _cancelled_runs
# status flipped eagerly so the UI reacts without waiting for a checkpoint
await db_session.refresh(run)
assert run.status == "cancelled"
assert run.finished_at is not None
# cleanup for other tests
_cancelled_runs.discard(run.id)
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
request_cancel(run_id)
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# nmap should not have been called — cancelled before first range
mock_nmap.assert_not_called()
await db_session.refresh(run)
assert run.status == "cancelled"
assert run.finished_at is not None
@pytest.mark.asyncio
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
db_session.add(run)
await db_session.commit()
call_count = 0
def nmap_side_effect(target: str, port_spec: str | None = None, run_id: str | None = None):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
if call_count == 1:
request_cancel(run_id)
return []
with (
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
assert call_count == 1 # second CIDR was skipped
await db_session.refresh(run)
assert run.status == "cancelled"
@pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate."""
# Pre-existing pending device with no services
existing = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(existing)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# Should still be only one device
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
devices = list(result.scalars().all())
assert len(devices) == 1
device = devices[0]
# Services and hostname should be updated
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
-80
View File
@@ -56,83 +56,3 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
settings.mcp_service_key = ""
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
assert res.status_code == 401
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
from app.core.config import settings
original = settings.auth_password_hash
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
try:
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 401
finally:
settings.auth_password_hash = original
# --- JWT-level cases ---
async def test_expired_token_rejected(client: AsyncClient):
"""A JWT whose `exp` is in the past must be refused."""
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
}
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
async def test_malformed_token_rejected(client: AsyncClient):
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
assert res.status_code == 401
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
"""A token signed with a different key must not be accepted."""
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
assert res.status_code == 401
async def test_missing_authorization_header_rejected(client: AsyncClient):
res = await client.get("/api/v1/nodes")
assert res.status_code == 401
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
"""No credentials configured server-side must not authenticate an empty password."""
from app.core.config import settings
original_hash = settings.auth_password_hash
settings.auth_password_hash = ""
try:
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
assert res.status_code == 401
finally:
settings.auth_password_hash = original_hash
# --- Password helper ---
def test_verify_password_handles_empty_inputs():
"""verify_password must be safe against empty plain / empty hash without raising."""
from app.core.security import hash_password, verify_password
h = hash_password("hunter2")
assert verify_password("hunter2", h) is True
assert verify_password("", h) is False
assert verify_password("hunter2", "") is False
assert verify_password("", "") is False
+7 -476
View File
@@ -1,8 +1,15 @@
import uuid
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
def node_payload(**kwargs):
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
@@ -22,23 +29,6 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
# A never-saved canvas has no CanvasState row → initialized False. The frontend
# uses this to show the demo canvas only to genuinely new users.
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["initialized"] is False
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
# loaded empty canvas is reported initialized — the user cleared it on purpose
# and must not get the demo re-seeded.
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
data = (await client.get("/api/v1/canvas", headers=headers)).json()
assert data["nodes"] == []
assert data["initialized"] is True
async def test_load_canvas_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/canvas")
assert res.status_code == 401
@@ -61,88 +51,6 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
async def test_save_canvas_round_trips_marker_shapes(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], marker_start="diamond", marker_end="arrow")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "diamond"
assert edge["marker_end"] == "arrow"
async def test_save_canvas_round_trips_line_style_and_width(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], type="wifi", line_style="dotted", width_mult=3)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["line_style"] == "dotted"
assert edge["width_mult"] == 3
async def test_save_canvas_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=False)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "arrow"
assert edge["marker_end"] == "none"
async def test_save_canvas_defaults_markers_none(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "none"
assert edge["marker_end"] == "none"
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
# Regression (#243): top/left/right_handles must persist across save+reload,
# not just bottom_handles.
n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4)
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
assert res.status_code == 200
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
assert node["top_handles"] == 2
assert node["bottom_handles"] == 3
assert node["left_handles"] == 1
assert node["right_handles"] == 4
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
n1 = node_payload(label="Srv", type="server")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
assert node["top_handles"] == 1
assert node["bottom_handles"] == 1
assert node["left_handles"] == 0
assert node["right_handles"] == 0
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
# created_at / updated_at always set; last_seen / last_scan null until observed.
assert node["created_at"] is not None
assert node["updated_at"] is not None
assert "last_seen" in node
assert node["last_scan"] is None
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
n1 = node_payload(label="Old Label")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
@@ -196,25 +104,6 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
"""label_position and text_size are stored in custom_colors and returned unchanged."""
n1 = node_payload(custom_colors={
"border": "#00d4ff",
"border_style": "solid",
"border_width": 3,
"label_position": "outside",
"text_size": 16,
"text_color": "#e6edf3",
})
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
cc = canvas["nodes"][0]["custom_colors"]
assert cc["label_position"] == "outside"
assert cc["text_size"] == 16
assert cc["border_width"] == 3
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
@@ -291,24 +180,6 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
assert canvas["nodes"][0]["show_hardware"] is False
# Regression (#184): show_port_numbers was dropped by the save schema, so the
# toggle reset on every reload.
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
n1 = node_payload(show_port_numbers=True)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["show_port_numbers"] is True
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["show_port_numbers"] is False
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, ram_gb=32.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
@@ -366,343 +237,3 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["width"] is None
assert canvas["nodes"][0]["height"] is None
# ── properties ────────────────────────────────────────────────────────────────
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
props = [
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["nodes"][0]["properties"]
assert len(returned) == 2
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_updated = {**n1, "properties": [
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
props = canvas["nodes"][0]["properties"]
assert len(props) == 2
assert props[0]["value"] == "64 GB"
assert props[1]["key"] == "Disk"
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"][0]["icon"] is None
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "properties": []}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
# ── edge waypoints & handles ──────────────────────────────────────────────────
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] is None
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["edges"][0]["waypoints"]
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=3)
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["source_handle"] == "bottom-1"
assert edge["target_handle"] == "top"
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "snake"
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "basic"
# ── node fields ───────────────────────────────────────────────────────────────
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
n1 = node_payload(
type="server",
label="Main Server",
hostname="server.local",
ip="192.168.1.10",
mac="aa:bb:cc:dd:ee:ff",
os="Ubuntu 22.04",
status="online",
check_method="http",
check_target="http://192.168.1.10",
services=[{"name": "nginx", "port": 80}],
notes="Primary web server",
pos_x=150.0,
pos_y=250.0,
bottom_handles=2,
)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["hostname"] == "server.local"
assert node["ip"] == "192.168.1.10"
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
assert node["os"] == "Ubuntu 22.04"
assert node["status"] == "online"
assert node["check_method"] == "http"
assert node["check_target"] == "http://192.168.1.10"
assert node["services"] == [{"name": "nginx", "port": 80}]
assert node["notes"] == "Primary web server"
assert node["pos_x"] == 150.0
assert node["pos_y"] == 250.0
assert node["bottom_handles"] == 2
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=4)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["bottom_handles"] == 4
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
n1 = node_payload()
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["bottom_handles"] == 1
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
n1 = node_payload(services=services, notes="My NAS device")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["services"] == services
assert node["notes"] == "My NAS device"
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
n1 = node_payload(ip="192.168.1.50:8080", services=services)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["services"] == services
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["check_method"] == "ping"
assert node["check_target"] == "192.168.1.1"
# ── parent/child nodes ────────────────────────────────────────────────────────
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[child["id"]]["parent_id"] == parent["id"]
assert node_map[parent["id"]]["parent_id"] is None
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
# Remove both parent and child
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"] == []
# ── groupRect / group node ────────────────────────────────────────────────────
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
member = node_payload(type="server", label="Member", parent_id=group["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[group["id"]]["type"] == "group"
assert node_map[group["id"]]["width"] == 400.0
assert node_map[group["id"]]["height"] == 300.0
assert node_map[member["id"]]["parent_id"] == group["id"]
# ── viewport ──────────────────────────────────────────────────────────────────
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
# ── edge types ────────────────────────────────────────────────────────────────
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["type"] == "vlan"
assert edge["vlan_id"] == 10
assert edge["label"] == "VLAN 10"
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
"""Second save updates an existing edge (exercises the db_edge branch)."""
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], label="original")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["label"] == "updated"
assert edge["custom_color"] == "#ff0000"
# ── custom_style ──────────────────────────────────────────────────────────────
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
custom_style = {
"nodes": {
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
},
"edges": {
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
},
}
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
assert res.status_code == 200
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["custom_style"] is not None
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/canvas", headers=headers)
assert res.status_code == 200
assert res.json()["custom_style"] is None
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert "proxmox" in canvas["custom_style"]["nodes"]
assert "server" not in canvas["custom_style"]["nodes"]
-270
View File
@@ -1,270 +0,0 @@
import uuid
from httpx import AsyncClient
def node_payload(**kwargs):
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
def edge_payload(src, tgt, **kwargs):
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
assert res.status_code == 201, res.text
return res.json()
# ── auth ──────────────────────────────────────────────────────────────────────
async def test_list_designs_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/designs")
assert res.status_code == 401
async def test_create_design_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/designs", json={"name": "X"})
assert res.status_code == 401
# ── list / create ─────────────────────────────────────────────────────────────
async def test_list_designs_empty(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/designs", headers=headers)
assert res.status_code == 200
assert res.json() == []
async def test_create_design_defaults(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Workshop")
assert design["name"] == "Workshop"
assert design["design_type"] == "network"
assert design["icon"] == "dashboard"
assert "id" in design and design["id"]
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Net", design_type="network")
assert design["design_type"] == "network"
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Power", icon="zap")
assert design["icon"] == "zap"
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="D", icon="dashboard")
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
assert res.status_code == 200
assert res.json()["icon"] == "server"
# Name left untouched when only icon is sent.
assert res.json()["name"] == "D"
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Old", icon="dashboard")
res = await client.put(
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["name"] == "New"
assert body["icon"] == "network"
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Has Canvas")
# Loading the new design returns an (empty) canvas without falling back to another design.
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
assert res.status_code == 200
body = res.json()
assert body["nodes"] == []
assert body["edges"] == []
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
a = await _create(client, headers, name="First")
b = await _create(client, headers, name="Second")
listed = (await client.get("/api/v1/designs", headers=headers)).json()
ids = [d["id"] for d in listed]
assert ids == [a["id"], b["id"]]
# ── counts in list ──────────────────────────────────────────────────────────
async def test_list_includes_node_group_text_counts(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Counted")
server = node_payload(label="S", type="server")
group = node_payload(label="G", type="groupRect")
text = node_payload(label="T", type="text")
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [server, group, text], "edges": [], "viewport": {}, "design_id": design["id"]},
headers=headers,
)
assert save.status_code == 200
listed = (await client.get("/api/v1/designs", headers=headers)).json()
d = next(x for x in listed if x["id"] == design["id"])
assert d["node_count"] == 1
assert d["group_count"] == 1
assert d["text_count"] == 1
async def test_list_counts_zero_for_empty_design(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Empty")
listed = (await client.get("/api/v1/designs", headers=headers)).json()
d = next(x for x in listed if x["id"] == design["id"])
assert d["node_count"] == 0
assert d["group_count"] == 0
assert d["text_count"] == 0
# ── copy ──────────────────────────────────────────────────────────────────────
async def test_copy_requires_auth(client: AsyncClient):
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"})
assert res.status_code == 401
async def test_copy_missing_source_returns_404(client: AsyncClient, headers: dict):
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}, headers=headers)
assert res.status_code == 404
async def test_copy_duplicates_nodes_edges_and_remaps_ids(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Source", icon="server")
n1 = node_payload(label="A")
n2 = node_payload(label="B")
e1 = edge_payload(n1["id"], n2["id"], label="link")
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 5, "y": 6, "zoom": 2}, "design_id": source["id"]},
headers=headers,
)
assert save.status_code == 200
res = await client.post(
f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy", "icon": "network"}, headers=headers,
)
assert res.status_code == 201, res.text
copy = res.json()
assert copy["name"] == "Copy"
assert copy["icon"] == "network"
assert copy["design_type"] == "network"
assert copy["id"] != source["id"]
# Copied canvas has the same shape but fresh node ids, and edge re-pointed.
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
assert {n["label"] for n in canvas["nodes"]} == {"A", "B"}
copied_ids = {n["id"] for n in canvas["nodes"]}
assert copied_ids.isdisjoint({n1["id"], n2["id"]})
assert len(canvas["edges"]) == 1
edge = canvas["edges"][0]
assert edge["source"] in copied_ids
assert edge["target"] in copied_ids
assert edge["label"] == "link"
assert canvas["viewport"] == {"x": 5, "y": 6, "zoom": 2}
async def test_copy_remaps_parent_child_relationship(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Nested")
parent = node_payload(label="P", type="proxmox", container_mode=True)
child = node_payload(label="C", type="vm", parent_id=parent["id"])
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [parent, child], "edges": [], "viewport": {}, "design_id": source["id"]},
headers=headers,
)
assert save.status_code == 200
res = await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
assert res.status_code == 201
copy = res.json()
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
by_label = {n["label"]: n for n in canvas["nodes"]}
# Child's parent_id points at the COPIED parent, not the original.
assert by_label["C"]["parent_id"] == by_label["P"]["id"]
assert by_label["C"]["parent_id"] != parent["id"]
async def test_copy_leaves_source_untouched(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Source")
n1 = node_payload(label="A")
await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1], "edges": [], "viewport": {}, "design_id": source["id"]},
headers=headers,
)
await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
src_canvas = (await client.get("/api/v1/canvas", params={"design_id": source["id"]}, headers=headers)).json()
assert len(src_canvas["nodes"]) == 1
assert src_canvas["nodes"][0]["id"] == n1["id"]
# ── update ────────────────────────────────────────────────────────────────────
async def test_update_design_renames(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Old Name")
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
assert res.status_code == 200
assert res.json()["name"] == "New Name"
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
assert res.status_code == 404
# ── delete ────────────────────────────────────────────────────────────────────
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Only One")
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
assert res.status_code == 400
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
await _create(client, headers, name="Keep")
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
assert res.status_code == 404
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
keep = await _create(client, headers, name="Keep")
victim = await _create(client, headers, name="Victim")
# Populate the victim design with nodes + an edge via canvas save.
n1 = node_payload(label="A")
n2 = node_payload(label="B")
e1 = edge_payload(n1["id"], n2["id"])
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
headers=headers,
)
assert save.status_code == 200
# Populate the kept design too, to prove scoping.
k1 = node_payload(label="K")
await client.post(
"/api/v1/canvas/save",
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
headers=headers,
)
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
assert res.status_code == 204
# Victim gone from list.
listed = (await client.get("/api/v1/designs", headers=headers)).json()
assert [d["id"] for d in listed] == [keep["id"]]
# Kept design's node survives untouched.
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
assert len(kept_canvas["nodes"]) == 1
assert kept_canvas["nodes"][0]["label"] == "K"
+7 -205
View File
@@ -2,6 +2,13 @@ import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
async def two_nodes(client: AsyncClient, headers: dict):
n1 = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
@@ -84,107 +91,12 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
assert res.json()["path_style"] == "smooth"
async def test_create_edge_with_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi", "line_style": "dotted", "width_mult": 3}, headers=headers)
assert res.status_code == 201
assert res.json()["line_style"] == "dotted"
assert res.json()["width_mult"] == 3
async def test_update_edge_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"line_style": "dashed", "width_mult": 2}, headers=headers)
assert res.status_code == 200
assert res.json()["line_style"] == "dashed"
assert res.json()["width_mult"] == 2
async def test_create_edge_defaults_line_style_none(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
assert res.status_code == 201
assert res.json()["line_style"] is None
assert res.json()["width_mult"] is None
async def test_create_edge_with_marker_shapes(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": "diamond", "marker_end": "arrow"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_start"] == "diamond"
assert res.json()["marker_end"] == "arrow"
async def test_create_edge_defaults_markers_none(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_start"] == "none"
assert res.json()["marker_end"] == "none"
async def test_create_edge_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": True}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_end"] == "arrow"
async def test_create_edge_rejects_unknown_marker_shape(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": "bogus"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_end"] == "none"
async def test_update_edge_marker_shape(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": "circle"}, headers=headers)
assert res.status_code == 200
assert res.json()["marker_end"] == "circle"
assert res.json()["marker_start"] == "none"
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
assert res.status_code == 401
async def test_create_edge_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict, two_nodes):
# Regression for #225: MCP create_edge sent no design_id, so edges were
# persisted with design_id=null and never rendered until a restart.
src, tgt = two_nodes
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
design_id = design.json()["id"]
res = await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "type": "ethernet"},
headers=headers,
)
assert res.status_code == 201
assert res.json()["design_id"] == design_id
async def test_create_edge_respects_explicit_design_id(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
second_id = second.json()["id"]
res = await client.post(
"/api/v1/edges",
json={"source": src, "target": tgt, "type": "ethernet", "design_id": second_id},
headers=headers,
)
assert res.status_code == 201
assert res.json()["design_id"] == second_id
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post(
@@ -220,113 +132,3 @@ async def test_source_and_target_handle_persist_through_update(client: AsyncClie
assert data["source_handle"] == "cluster-right"
assert data["target_handle"] == "cluster-left"
assert data["label"] == "corosync"
# ---------------------------------------------------------------------------
# Auto edge handles (issue #265): omitting source_handle/target_handle lets
# the backend infer up/downstream sides from the nodes' absolute canvas Y.
# ---------------------------------------------------------------------------
@pytest.fixture
async def stacked_nodes(client: AsyncClient, headers: dict):
"""Two root nodes at known, distinct Y positions (top at y=0, bottom at y=300)."""
top = (
await client.post(
"/api/v1/nodes",
json={"type": "router", "label": "Top", "status": "online", "pos_x": 0, "pos_y": 0},
headers=headers,
)
).json()
bottom = (
await client.post(
"/api/v1/nodes",
json={"type": "switch", "label": "Bottom", "status": "online", "pos_x": 0, "pos_y": 300},
headers=headers,
)
).json()
return top["id"], bottom["id"]
async def test_create_edge_auto_handles_source_above_target(client: AsyncClient, headers: dict, stacked_nodes):
"""Source above target -> downstream flow: exit bottom, enter top-t."""
top, bottom = stacked_nodes
res = await client.post(
"/api/v1/edges", json={"source": top, "target": bottom, "type": "ethernet"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
async def test_create_edge_auto_handles_source_below_target(client: AsyncClient, headers: dict, stacked_nodes):
"""Source below target -> upstream flow: exit top, enter bottom-t."""
top, bottom = stacked_nodes
res = await client.post(
"/api/v1/edges", json={"source": bottom, "target": top, "type": "ethernet"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "top"
assert data["target_handle"] == "bottom-t"
async def test_create_edge_auto_handles_equal_y_defaults_downstream(client: AsyncClient, headers: dict, two_nodes):
"""Same Y (auto-placed side by side) falls back to the default bottom/top-t."""
src, tgt = two_nodes
res = await client.post(
"/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
async def test_create_edge_partial_handle_auto_fills_the_other(client: AsyncClient, headers: dict, stacked_nodes):
"""An explicit source_handle is kept; the omitted target_handle is inferred."""
top, bottom = stacked_nodes
res = await client.post(
"/api/v1/edges",
json={"source": top, "target": bottom, "type": "ethernet", "source_handle": "cluster-right"},
headers=headers,
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "cluster-right"
assert data["target_handle"] == "top-t"
async def test_create_edge_child_node_abs_y_resolved_through_parent(client: AsyncClient, headers: dict):
"""A child's absolute Y = parent Y + its own pos_y, so a VM low inside a high
container still resolves as downstream of a node above the container."""
parent = (
await client.post(
"/api/v1/nodes",
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True, "pos_x": 0, "pos_y": 0},
headers=headers,
)
).json()
child = (
await client.post(
"/api/v1/nodes",
json={"type": "vm", "label": "VM", "status": "online", "parent_id": parent["id"], "pos_x": 0, "pos_y": 50},
headers=headers,
)
).json()
below = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Below", "status": "online", "pos_x": 0, "pos_y": 500},
headers=headers,
)
).json()
# child abs Y = 0 + 50 = 50, below at 500 -> child is upstream.
res = await client.post(
"/api/v1/edges", json={"source": child["id"], "target": below["id"], "type": "virtual"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
+1 -203
View File
@@ -2,13 +2,7 @@ from unittest.mock import patch
import pytest
from app.services.fingerprint import (
fingerprint_ports,
match_port,
match_service,
suggest_node_type,
suggest_type_from_mac,
)
from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type
MOCK_SIGNATURES = [
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
@@ -137,199 +131,3 @@ def test_suggest_node_type_camera_from_signature():
]):
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
assert result == "camera"
# ── IoT detection ─────────────────────────────────────────────────────────────
def test_suggest_node_type_iot_from_mqtt_port():
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_coap_port():
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_esphome_port():
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_shelly_mac_overrides_http_port():
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_espressif_mac_returns_iot():
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
assert result == "iot"
def test_suggest_node_type_tuya_mac_returns_iot():
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
result = suggest_node_type([
{"port": 80, "protocol": "tcp"},
{"port": 1883, "protocol": "tcp"},
])
assert result == "iot"
# ── OUI vendor detection ──────────────────────────────────────────────────────
def test_suggest_type_from_mac_mikrotik_returns_router():
# The motivating case: MikroTik MAC should be recognized as a router
assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router"
assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router"
def test_suggest_type_from_mac_ubiquiti_returns_ap():
# Ubiquiti makes routers, switches, APs, cameras — most homelab gear is UniFi APs,
# so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open.
assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap"
assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap"
def test_suggest_type_from_mac_synology_returns_nas():
assert suggest_type_from_mac("00:11:32:11:22:33") == "nas"
def test_suggest_type_from_mac_qnap_returns_nas():
assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas"
def test_suggest_type_from_mac_hikvision_returns_camera():
assert suggest_type_from_mac("28:57:be:11:22:33") == "camera"
def test_suggest_type_from_mac_dahua_returns_camera():
assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera"
def test_suggest_type_from_mac_cisco_returns_switch():
assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch"
def test_suggest_type_from_mac_raspberry_pi_returns_server():
assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server"
def test_suggest_type_from_mac_handles_uppercase():
# MACs may arrive in any case; lookup must be case-insensitive
assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router"
def test_suggest_type_from_mac_unknown_oui_returns_none():
assert suggest_type_from_mac("00:00:01:11:22:33") is None
def test_suggest_node_type_mikrotik_mac_returns_router_no_ports():
# MikroTik device with no scanned ports should still be classified as router via MAC
assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router"
def test_suggest_node_type_synology_mac_with_http_returns_nas():
# NAS priority beats server, so a Synology MAC + open HTTP → nas
result = suggest_node_type(
[{"port": 80, "protocol": "tcp"}],
mac="00:11:32:11:22:33",
)
assert result == "nas"
def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
# Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority)
result = suggest_node_type(
[{"port": 179, "protocol": "tcp"}],
mac="24:a4:3c:11:22:33",
)
assert result == "router"
# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
HTTP_SIGNATURES = [
# Generic web fallback on 8096 (port-only guess)
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
"service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
# Same port, but confirmed by HTML title → should win when probe confirms
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
# Port-agnostic: matches on HTTP content regardless of port
{"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
"service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
# Banner-based entry, no http
{"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
"service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
]
@pytest.fixture
def http_signatures():
with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
yield
def test_http_regex_confirmed_beats_port_only(http_signatures):
# Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
sig = match_service(8096, "tcp", banner=None,
http_signals={"title": "Jellyfin", "headers": {}})
assert sig["service_name"] == "Jellyfin"
def test_http_regex_matches_on_header(http_signatures):
sig = match_service(8096, "tcp", banner=None,
http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
assert sig["service_name"] == "Jellyfin"
def test_http_regex_miss_falls_back_to_port_only(http_signatures):
# Probe ran but nothing matched the http_regex → generic port-only entry wins
sig = match_service(8096, "tcp", banner=None,
http_signals={"title": "Some Other App", "headers": {}})
assert sig["service_name"] == "HTTP"
def test_probe_disabled_ignores_http_regex(http_signatures):
# http_signals=None (deep scan off) → http_regex entry degrades to port-only,
# generic entry (listed first) wins — identical to pre-probe behaviour.
sig = match_service(8096, "tcp", banner=None, http_signals=None)
assert sig["service_name"] == "HTTP"
def test_port_agnostic_match_on_custom_port(http_signatures):
# Portainer found on a non-standard port, recognised purely by HTTP content
sig = match_service(54321, "tcp", banner=None,
http_signals={"title": "Portainer", "headers": {}})
assert sig["service_name"] == "Portainer"
def test_port_agnostic_requires_probe(http_signatures):
# Same custom port, probe off → no signal → no match
assert match_service(54321, "tcp", banner=None, http_signals=None) is None
def test_banner_match_still_works_with_probe(http_signatures):
sig = match_service(9090, "tcp", banner="prometheus 2.x",
http_signals={"title": "x", "headers": {}})
assert sig["service_name"] == "Prometheus"
def test_match_port_alias_has_no_http(http_signatures):
# match_port() is the probe-less alias → http_regex entry degrades to port-only
sig = match_port(8096, "tcp")
assert sig["service_name"] == "HTTP"
def test_fingerprint_ports_uses_http_signals(http_signatures):
results = fingerprint_ports([
{"port": 8096, "protocol": "tcp", "banner": None,
"http_signals": {"title": "Jellyfin", "headers": {}}},
])
assert results[0]["service_name"] == "Jellyfin"
-118
View File
@@ -1,118 +0,0 @@
"""Tests for the HTTP probe used by deep-scan service identification."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from app.services.http_probe import (
_extract_title,
probe_open_ports,
probe_port,
)
def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response:
return httpx.Response(status_code=status, text=text, headers=headers or {})
# ── _extract_title ──────────────────────────────────────────────────────────
def test_extract_title_basic():
assert _extract_title("<html><title>Jellyfin</title></html>") == "Jellyfin"
def test_extract_title_collapses_whitespace():
assert _extract_title("<title>\n My App\n</title>") == "My App"
def test_extract_title_missing():
assert _extract_title("<html><body>no title</body></html>") is None
def test_extract_title_case_insensitive():
assert _extract_title("<TITLE>Portainer</TITLE>") == "Portainer"
# ── probe_port ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_probe_port_reads_title():
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
result = await probe_port("10.0.0.5", 8096)
assert result == {"title": "Jellyfin", "headers": {}}
@pytest.mark.asyncio
async def test_probe_port_reads_headers():
resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
result = await probe_port("10.0.0.5", 3000)
assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
@pytest.mark.asyncio
async def test_probe_port_falls_back_to_http():
# https raises, http succeeds
calls = {"n": 0}
async def fake_get(self, url, **kw):
calls["n"] += 1
if url.startswith("https"):
raise httpx.ConnectError("tls fail")
return _response("<title>HTTP App</title>")
with patch("httpx.AsyncClient.get", new=fake_get):
result = await probe_port("10.0.0.5", 8080)
assert result["title"] == "HTTP App"
assert calls["n"] == 2 # tried https then http
@pytest.mark.asyncio
async def test_probe_port_no_signal_returns_none():
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
result = await probe_port("10.0.0.5", 8080)
assert result is None
@pytest.mark.asyncio
async def test_probe_port_timeout_returns_none():
with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
result = await probe_port("10.0.0.5", 8080)
assert result is None
@pytest.mark.asyncio
async def test_probe_port_skips_non_http_ports():
# SSH should never trigger an HTTP request
get = AsyncMock()
with patch("httpx.AsyncClient.get", new=get):
result = await probe_port("10.0.0.5", 22)
assert result is None
get.assert_not_called()
@pytest.mark.asyncio
async def test_probe_port_verify_tls_flag_passed():
with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
instance = client_cls.return_value.__aenter__.return_value
instance.get = AsyncMock(return_value=_response("<title>X</title>"))
await probe_port("10.0.0.5", 8443, verify_tls=True)
assert client_cls.call_args.kwargs["verify"] is True
# ── probe_open_ports ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_probe_open_ports_enriches_each_port():
async def fake_get(self, url, **kw):
if ":8096" in url:
return _response("<title>Jellyfin</title>")
return _response("")
ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
with patch("httpx.AsyncClient.get", new=fake_get):
result = await probe_open_ports("10.0.0.5", ports)
by_port = {p["port"]: p for p in result}
assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
assert by_port[9999]["http_signals"] is None
-223
View File
@@ -1,223 +0,0 @@
"""
Tests for the /api/v1/liveview read-only canvas endpoint.
The endpoint is:
- Disabled by default (LIVEVIEW_KEY not set) 403
- Returns 403 for missing or wrong key even when enabled
- Returns canvas data for a valid key (no JWT required)
"""
import pytest
from httpx import AsyncClient
from app.core.config import settings
@pytest.fixture(autouse=True)
def reset_liveview_key():
"""Restore liveview_key after each test so tests are isolated."""
original = settings.liveview_key
yield
settings.liveview_key = original
# ── Disabled (no key configured) ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_by_default(client: AsyncClient):
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
@pytest.mark.asyncio
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
settings.liveview_key = ""
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
# ── Enabled but wrong / missing key ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_wrong_key(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview?key=wrong-key")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
@pytest.mark.asyncio
async def test_liveview_missing_key_param(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
# ── Valid key — no JWT needed ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
settings.liveview_key = "my-secret-key"
res = await client.get("/api/v1/liveview?key=my-secret-key")
assert res.status_code == 200
data = res.json()
assert "nodes" in data
assert "edges" in data
assert "viewport" in data
assert isinstance(data["nodes"], list)
assert isinstance(data["edges"], list)
@pytest.mark.asyncio
async def test_liveview_does_not_require_jwt(client: AsyncClient):
"""Accessing without Authorization header must work when key is correct."""
settings.liveview_key = "open-sesame"
# client has no auth headers set here
res = await client.get("/api/v1/liveview?key=open-sesame")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_liveview_returns_saved_canvas(client: AsyncClient, headers):
"""Canvas saved via POST /canvas/save appears in liveview response."""
settings.liveview_key = "test-key"
# Save a canvas with one node
payload = {
"nodes": [{
"id": "lv-node-1",
"type": "server",
"label": "Live Node",
"status": "online",
"services": [],
"pos_x": 10,
"pos_y": 20,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Liveview should return the same node
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert len(nodes) == 1
assert nodes[0]["id"] == "lv-node-1"
assert nodes[0]["label"] == "Live Node"
# ── custom_style + theme propagation ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, headers):
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
settings.liveview_key = "test-key"
payload = {
"nodes": [],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
body = res.json()
assert body["viewport"].get("theme_id") == "matrix"
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
# ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
settings.liveview_key = "was-enabled"
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 200
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
# ── /config (authenticated) — key used to build share links ──────────────────
@pytest.mark.asyncio
async def test_liveview_config_requires_auth(client: AsyncClient):
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
settings.liveview_key = "secret"
res = await client.get("/api/v1/liveview/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, headers):
settings.liveview_key = "share-me"
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
body = res.json()
assert body == {"enabled": True, "key": "share-me"}
@pytest.mark.asyncio
async def test_liveview_config_disabled_hides_key(client: AsyncClient, headers):
settings.liveview_key = None
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
@pytest.mark.asyncio
async def test_liveview_config_empty_key_disabled(client: AsyncClient, headers):
settings.liveview_key = ""
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
# ── design_id selects which canvas is rendered ───────────────────────────────
@pytest.mark.asyncio
async def test_liveview_design_id_selects_canvas(client: AsyncClient, headers):
"""?design_id=<id> renders that design's canvas, not the first one."""
settings.liveview_key = "test-key"
# Create two designs
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
# Save a distinct node into each design
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
payload = {
"nodes": [{
"id": node_id,
"type": "server",
"label": label,
"status": "online",
"services": [],
"pos_x": 0,
"pos_y": 0,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
"design_id": design["id"],
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Requesting d2 returns only the electrical node
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert [n["id"] for n in nodes] == ["n-elec"]
# Requesting d1 returns only the network node
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert [n["id"] for n in nodes] == ["n-net"]
-27
View File
@@ -1,27 +0,0 @@
"""Unit tests for MAC normalization (the cross-source dedup key)."""
from __future__ import annotations
from app.services.discovery_sources import add_source
from app.services.mac_utils import normalize_mac
def test_normalize_mac_lowercases_and_unifies_separators() -> None:
assert normalize_mac("BC:24:11:AA:BB:CC") == "bc:24:11:aa:bb:cc"
assert normalize_mac("bc-24-11-aa-bb-cc") == "bc:24:11:aa:bb:cc"
assert normalize_mac(" BC:24:11:AA:BB:CC ") == "bc:24:11:aa:bb:cc"
def test_normalize_mac_blank_is_none() -> None:
assert normalize_mac(None) is None
assert normalize_mac("") is None
assert normalize_mac(" ") is None
def test_add_source_unions_without_duplicates() -> None:
assert add_source(None, "arp") == ["arp"]
assert add_source(["arp"], "proxmox") == ["arp", "proxmox"]
assert add_source(["arp", "proxmox"], "proxmox") == ["arp", "proxmox"]
assert add_source(["arp"], None) == ["arp"]
# Drops falsy members already present.
assert add_source(["arp", ""], "proxmox") == ["arp", "proxmox"]
-94
View File
@@ -1,94 +0,0 @@
import re
import pytest
from app.api.routes import media
from app.core.config import settings
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
@pytest.fixture
def media_dir(tmp_path, monkeypatch):
"""Point uploads at a temp folder for the duration of a test."""
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
return tmp_path
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
return await client.post(
"/api/v1/media/upload",
files={"file": (name, data, content_type)},
headers=headers,
)
@pytest.mark.asyncio
async def test_upload_requires_auth(client, media_dir):
res = await _upload(client, headers={})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_upload_stores_file_and_returns_url(client, headers, media_dir):
res = await _upload(client, headers)
assert res.status_code == 200
body = res.json()
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
# File written to disk with the server-generated name (client name ignored).
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
assert body["filename"] != "plan.png"
@pytest.mark.asyncio
async def test_upload_rejects_unsupported_type(client, headers, media_dir):
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
assert res.status_code == 415
@pytest.mark.asyncio
async def test_upload_rejects_content_type_magic_mismatch(client, headers, media_dir):
# Claims PNG but bytes are not a PNG.
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
assert res.status_code == 415
@pytest.mark.asyncio
async def test_upload_rejects_oversize(client, headers, media_dir, monkeypatch):
monkeypatch.setattr(media, "MAX_BYTES", 8)
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
assert res.status_code == 413
@pytest.mark.asyncio
async def test_get_serves_uploaded_file(client, headers, media_dir):
up = await _upload(client, headers)
res = await client.get(up.json()["url"]) # public, no auth
assert res.status_code == 200
assert res.content == PNG_BYTES
@pytest.mark.asyncio
async def test_get_rejects_bad_filename(client, media_dir):
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_rejects_bad_filename(client, headers, media_dir):
res = await client.delete("/api/v1/media/..%2f..%2fetc%2fpasswd", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_requires_auth_and_removes_file(client, headers, media_dir):
up = await _upload(client, headers)
filename = up.json()["filename"]
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
assert (media_dir / filename).exists()
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
assert res.status_code == 204
assert not (media_dir / filename).exists()
assert (await client.get(up.json()["url"])).status_code == 404
-183
View File
@@ -1,183 +0,0 @@
"""Unit tests for the shared MQTT helpers in mqtt_common."""
from __future__ import annotations
import json
import ssl
from unittest.mock import patch
import pytest
from app.services.mqtt_common import (
_build_tls_context,
_sanitize_mqtt_error,
request_response,
)
from app.services.mqtt_common import test_connection as _test_connection
# ---------------------------------------------------------------------------
# _sanitize_mqtt_error — never leak credentials
# ---------------------------------------------------------------------------
def test_sanitize_auth_error() -> None:
msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
assert msg == "Authentication failed"
assert "secret" not in msg
def test_sanitize_refused() -> None:
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
def test_sanitize_dns() -> None:
msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
assert msg == "Broker hostname could not be resolved"
assert "broker.lan" not in msg
def test_sanitize_tls() -> None:
assert _sanitize_mqtt_error(
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
) == "TLS handshake failed"
def test_sanitize_timeout() -> None:
assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
def test_sanitize_unknown_falls_back() -> None:
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
assert msg == "MQTT connection failed"
assert "hunter2" not in msg
# ---------------------------------------------------------------------------
# _build_tls_context
# ---------------------------------------------------------------------------
def test_tls_secure_verifies() -> None:
ctx = _build_tls_context(insecure=False)
assert ctx.check_hostname is True
assert ctx.verify_mode == ssl.CERT_REQUIRED
def test_tls_insecure_disables_verification() -> None:
ctx = _build_tls_context(insecure=True)
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE
# ---------------------------------------------------------------------------
# request_response (mocked aiomqtt)
# ---------------------------------------------------------------------------
_SAMPLE = {"success": True, "result": []}
def _fake_client_factory(topic: str, payload: dict):
class _FakeMessage:
_yielded = False
def __init__(self) -> None:
self.topic = topic
self.payload = json.dumps(payload).encode()
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return self
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, _topic: str) -> None:
pass
async def publish(self, _topic: str, _payload: str) -> None:
pass
@property
def messages(self):
return _FakeMessage()
return _FakeClient
@pytest.mark.asyncio
async def test_request_response_success() -> None:
topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
mock_aiomqtt.MqttError = Exception
out = await request_response(
"localhost", 1883, "req/topic", topic, {"args": []}
)
assert out == _SAMPLE
@pytest.mark.asyncio
async def test_request_response_connection_error() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("Connection refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await request_response("bad", 1883, "req", "resp", {})
@pytest.mark.asyncio
async def test_request_response_passes_tls_context() -> None:
topic = "resp"
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
mock_aiomqtt.MqttError = Exception
await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
assert ctx.verify_mode == ssl.CERT_NONE
@pytest.mark.asyncio
async def test_test_connection_success() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
assert await _test_connection("localhost", 1883) is True
@pytest.mark.asyncio
async def test_test_connection_failure() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("refused")
async def __aexit__(self, *_):
pass
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await _test_connection("bad", 1883)
-117
View File
@@ -1,117 +0,0 @@
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe)."""
import pytest
from sqlalchemy import select
from app.db.models import Design, Edge, Node
from app.services.node_dedupe import dedupe_nodes_by_ieee
async def _design(db, name="d1"):
d = Design(name=name)
db.add(d)
await db.flush()
return d
@pytest.mark.asyncio
async def test_collapses_same_ieee_same_design(db_session):
d = await _design(db_session)
keep = Node(
label="Sensor", type="zigbee_enddevice", design_id=d.id,
ieee_address="0xAAA", properties=[{"key": "IEEE", "value": "0xAAA", "visible": True}],
pos_x=100, pos_y=200,
)
db_session.add(keep)
await db_session.flush()
dup = Node(
label="Sensor", type="zigbee_enddevice", design_id=d.id,
ieee_address="0xAAA", ip="10.0.0.5",
properties=[{"key": "LQI", "value": "88", "visible": False}],
)
db_session.add(dup)
await db_session.flush()
removed = await dedupe_nodes_by_ieee(db_session)
assert removed == 1
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xAAA"))).scalars().all()
assert len(nodes) == 1
survivor = nodes[0]
assert survivor.id == keep.id # oldest kept
assert survivor.pos_x == 100 # canvas position preserved
assert survivor.ip == "10.0.0.5" # missing field filled from dup
keys = {p["key"] for p in survivor.properties}
assert keys == {"IEEE", "LQI"} # properties merged
@pytest.mark.asyncio
async def test_preserves_same_ieee_across_designs(db_session):
"""Same device on two canvases is valid — must NOT be merged."""
d1 = await _design(db_session, "d1")
d2 = await _design(db_session, "d2")
for d in (d1, d2):
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, ieee_address="0xBBB"))
await db_session.flush()
removed = await dedupe_nodes_by_ieee(db_session)
assert removed == 0
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xBBB"))).scalars().all()
assert len(nodes) == 2
@pytest.mark.asyncio
async def test_repoints_edges_and_drops_dupes(db_session):
d = await _design(db_session)
keep = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
dup = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
other = Node(label="B", type="server", design_id=d.id)
db_session.add_all([keep, other])
await db_session.flush()
db_session.add(dup)
await db_session.flush()
# keep<->other and dup<->other (parallel after repoint), plus dup<->keep (self-loop).
db_session.add_all([
Edge(source=keep.id, target=other.id, type="ethernet", design_id=d.id),
Edge(source=dup.id, target=other.id, type="ethernet", design_id=d.id),
Edge(source=dup.id, target=keep.id, type="ethernet", design_id=d.id),
])
await db_session.flush()
removed = await dedupe_nodes_by_ieee(db_session)
assert removed == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
# self-loop dropped, parallel edge collapsed -> a single keep<->other edge
assert len(edges) == 1
e = edges[0]
assert {e.source, e.target} == {keep.id, other.id}
@pytest.mark.asyncio
async def test_repoints_child_parent(db_session):
d = await _design(db_session)
keep = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
dup = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
db_session.add(keep)
await db_session.flush()
db_session.add(dup)
await db_session.flush()
child = Node(label="VM", type="vm", design_id=d.id, parent_id=dup.id)
db_session.add(child)
await db_session.flush()
await dedupe_nodes_by_ieee(db_session)
await db_session.refresh(child)
assert child.parent_id == keep.id
@pytest.mark.asyncio
async def test_idempotent_noop_when_unique(db_session):
d = await _design(db_session)
db_session.add(Node(label="X", type="server", design_id=d.id, ieee_address="0xEEE"))
await db_session.flush()
assert await dedupe_nodes_by_ieee(db_session) == 0
assert await dedupe_nodes_by_ieee(db_session) == 0
+8 -256
View File
@@ -1,6 +1,14 @@
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
async def test_list_nodes_empty(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/nodes", headers=headers)
assert res.status_code == 200
@@ -59,91 +67,6 @@ async def test_update_node_not_found(client: AsyncClient, headers: dict):
assert res.status_code == 404
async def test_create_node_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict):
# Regression for #225: MCP create_node sent no design_id, so nodes were
# persisted with design_id=null and never rendered on the canvas until a
# container restart reconciled them. They must attach to a design on create.
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
design_id = design.json()["id"]
res = await client.post(
"/api/v1/nodes",
json={"type": "generic", "label": "mcp-node", "ip": "192.168.18.99"},
headers=headers,
)
assert res.status_code == 201
assert res.json()["design_id"] == design_id
async def test_create_node_respects_explicit_design_id(client: AsyncClient, headers: dict):
# When a design_id is supplied it must win over the first-design fallback.
first = await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
second_id = second.json()["id"]
assert first.json()["id"] != second_id
res = await client.post(
"/api/v1/nodes",
json={"type": "generic", "label": "n", "design_id": second_id},
headers=headers,
)
assert res.status_code == 201
assert res.json()["design_id"] == second_id
async def test_create_node_rejects_duplicate_ip_on_same_design(client: AsyncClient, headers: dict):
# A second node with the same ip on the same design is a silent duplicate —
# scripts/MCP clients get 409 with the existing node id instead. (#260)
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
design_id = design.json()["id"]
first = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
headers=headers,
)
assert first.status_code == 201
existing_id = first.json()["id"]
dup = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv-again", "ip": "192.168.1.5", "design_id": design_id},
headers=headers,
)
assert dup.status_code == 409
detail = dup.json()["detail"]
assert detail["duplicate"] is True
assert detail["existing_node_id"] == existing_id
assert detail["match"] == "ip"
async def test_create_node_force_bypasses_duplicate_guard(client: AsyncClient, headers: dict):
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
design_id = design.json()["id"]
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
headers=headers,
)
forced = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id, "force": True},
headers=headers,
)
assert forced.status_code == 201
async def test_create_node_without_any_design_stays_null(client: AsyncClient, headers: dict):
# No designs exist yet: fallback can't invent one, so design_id stays null
# rather than erroring.
res = await client.post(
"/api/v1/nodes",
json={"type": "generic", "label": "orphan"},
headers=headers,
)
assert res.status_code == 201
assert res.json()["design_id"] is None
async def test_delete_node_not_found(client: AsyncClient, headers: dict):
res = await client.delete("/api/v1/nodes/nonexistent", headers=headers)
assert res.status_code == 404
@@ -192,174 +115,3 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
async def test_create_node_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
assert res.status_code == 401
# --- Properties tests ---
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
"""New node has an empty properties list by default."""
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
assert res.status_code == 201
assert res.json()["properties"] == []
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
"""Node created with properties round-trips correctly."""
props = [
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
]
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
assert res.status_code == 201
assert res.json()["properties"] == props
async def test_patch_node_properties(client: AsyncClient, headers: dict):
"""PATCH with properties replaces the full properties array."""
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
node_id = create.json()["id"]
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
"""PATCH that omits properties leaves existing properties untouched."""
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
# PATCH only the label — properties must survive
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
assert res.json()["label"] == "Updated"
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
"""PATCH with properties=[] explicitly clears all properties."""
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == []
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
"""GET /nodes/:id returns the properties field."""
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
"""A property with icon=null is valid and round-trips correctly."""
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
create = await client.post(
"/api/v1/nodes",
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
headers=headers,
)
assert create.status_code == 201
assert create.json()["properties"] == props
# ---------------------------------------------------------------------------
# Auto-positioning (issue #265): omitting pos_x/pos_y snaps the node to the
# first free 200x100 grid slot instead of stacking everything at (0, 0).
# ---------------------------------------------------------------------------
async def test_create_node_auto_positions_first_at_origin(client: AsyncClient, headers: dict):
"""First root node with no coordinates lands at (0, 0)."""
res = await client.post(
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
async def test_create_node_auto_position_avoids_collision(client: AsyncClient, headers: dict):
"""A second auto-placed root node takes the next free grid cell, not (0, 0)."""
first = await client.post(
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
)
assert (first.json()["pos_x"], first.json()["pos_y"]) == (0.0, 0.0)
second = await client.post(
"/api/v1/nodes", json={"type": "server", "label": "B", "status": "unknown"}, headers=headers
)
assert second.status_code == 201
# Next free cell in row 0 is column 1 -> x = 1 * 200.
assert (second.json()["pos_x"], second.json()["pos_y"]) == (200.0, 0.0)
async def test_create_node_child_defaults_to_parent_origin(client: AsyncClient, headers: dict):
"""A child node (parent_id set) with no coordinates defaults to (0, 0) relative to its parent."""
parent = (
await client.post(
"/api/v1/nodes",
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True},
headers=headers,
)
).json()
res = await client.post(
"/api/v1/nodes",
json={"type": "vm", "label": "VM1", "status": "online", "parent_id": parent["id"]},
headers=headers,
)
assert res.status_code == 201
data = res.json()
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
async def test_create_node_explicit_position_preserved(client: AsyncClient, headers: dict):
"""Explicit coordinates are honored, never auto-placed."""
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "A", "status": "unknown", "pos_x": 512.0, "pos_y": 384.0},
headers=headers,
)
assert res.status_code == 201
data = res.json()
assert (data["pos_x"], data["pos_y"]) == (512.0, 384.0)
async def test_create_node_explicit_zero_position_preserved(client: AsyncClient, headers: dict):
"""pos=0 is an explicit value, not 'omitted' — auto-position must not treat 0 as None."""
await client.post(
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
)
# Second node explicitly pinned to (0, 0) even though the cell is taken.
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "B", "status": "unknown", "pos_x": 0, "pos_y": 0},
headers=headers,
)
assert res.status_code == 201
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
-489
View File
@@ -1,489 +0,0 @@
"""API + persistence tests for /api/v1/proxmox/*."""
from __future__ import annotations
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.api.routes.proxmox import (
_background_proxmox_import,
_guest_visibility_advisory,
_persist_pending_import,
)
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
from app.core.config import settings
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
@pytest.fixture(autouse=True)
def _clear_env_token():
"""Ensure a clean token state per test; restore afterwards."""
tid, sec = settings.proxmox_token_id, settings.proxmox_token_secret
settings.proxmox_token_id = ""
settings.proxmox_token_secret = ""
yield
settings.proxmox_token_id, settings.proxmox_token_secret = tid, sec
def _host_node() -> dict:
return {
"id": "pve-node-pve1", "label": "pve1", "type": "proxmox",
"ieee_address": "pve-node-pve1", "hostname": "pve1", "ip": None,
"status": "online", "cpu_count": 8, "ram_gb": 16.0, "disk_gb": 500.0,
"vendor": "Proxmox VE", "model": None, "parent_ieee": None,
}
def _guest_node(vmid: int, ip: str | None, status: str = "online", mac: str | None = None) -> dict:
return {
"id": f"pve-pve1-{vmid}", "label": f"vm{vmid}", "type": "vm",
"ieee_address": f"pve-pve1-{vmid}", "hostname": f"vm{vmid}", "ip": ip,
"mac": mac,
"status": status, "cpu_count": 2, "ram_gb": 4.0, "disk_gb": 32.0,
"vendor": "Proxmox VE", "model": "QEMU", "vmid": vmid,
"parent_ieee": "pve-node-pve1",
}
# --- endpoints -------------------------------------------------------------
@pytest.mark.asyncio
async def test_test_connection_uses_body_token(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.proxmox.test_proxmox_connection", new=AsyncMock(return_value=(True, "ok"))):
res = await client.post(
"/api/v1/proxmox/test-connection",
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
headers=headers,
)
assert res.status_code == 200
assert res.json()["connected"] is True
@pytest.mark.asyncio
async def test_missing_token_is_rejected(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/proxmox/test-connection",
json={"host": "pve", "port": 8006},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_import_pending_creates_scan_run(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
res = await client.post(
"/api/v1/proxmox/import-pending",
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "proxmox"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(client: AsyncClient, headers: dict) -> None:
settings.proxmox_host = "pve"
settings.proxmox_token_id = "u@pam!t"
settings.proxmox_token_secret = "s"
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
res = await client.post("/api/v1/proxmox/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "proxmox"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_token(client: AsyncClient, headers: dict) -> None:
settings.proxmox_host = "pve"
# _clear_env_token leaves token empty
res = await client.post("/api/v1/proxmox/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/proxmox/sync-now")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/proxmox/import-pending", json={"host": "pve"})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_config_omits_token(client: AsyncClient, headers: dict) -> None:
settings.proxmox_token_id = "u@pam!t"
settings.proxmox_token_secret = "supersecret"
res = await client.get("/api/v1/proxmox/config", headers=headers)
assert res.status_code == 200
body = res.text
assert "supersecret" not in body
assert res.json()["token_configured"] is True
@pytest.mark.asyncio
async def test_enable_sync_without_token_rejected(client: AsyncClient, headers: dict) -> None:
# _clear_env_token leaves token empty → enabling auto-sync is rejected.
res = await client.post(
"/api/v1/proxmox/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(client: AsyncClient, headers: dict) -> None:
"""Connection config is env-only: even if a client sends host/port/verify,
the endpoint ignores them and persists only the sync activation."""
settings.proxmox_host = "pve"
settings.proxmox_token_id = "u@pam!t"
settings.proxmox_token_secret = "s"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.proxmox_host, enabled=self.proxmox_sync_enabled, interval=self.proxmox_sync_interval
)), patch("app.api.routes.proxmox.set_proxmox_sync_enabled"), \
patch("app.api.routes.proxmox.reschedule_proxmox_sync"):
res = await client.post(
"/api/v1/proxmox/config",
json={"host": "attacker", "port": 1, "verify_tls": False, "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
# host untouched by the request body; sync activation applied.
assert settings.proxmox_host == "pve"
assert saved == {"host": "pve", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_background_import_broadcasts_refresh() -> None:
"""After persisting, the import emits a scan update so an open inventory
reloads without a manual refresh (same signal the IP scan uses)."""
fake_db = AsyncMock()
fake_db.get = AsyncMock(return_value=None) # no ScanRun row → skip status update
cm = AsyncMock()
cm.__aenter__.return_value = fake_db
cm.__aexit__.return_value = False
with patch("app.api.routes.proxmox.AsyncSessionLocal", MagicMock(return_value=cm)), \
patch("app.api.routes.proxmox.fetch_proxmox_inventory", new=AsyncMock(return_value=([], []))), \
patch("app.api.routes.proxmox._persist_pending_import",
new=AsyncMock(return_value=SimpleNamespace(device_count=3))), \
patch("app.api.routes.status.broadcast_scan_update", new=AsyncMock()) as bcast:
await _background_proxmox_import("run1", "h", 8006, "u@pam!t", "s", True)
bcast.assert_awaited_once()
assert bcast.await_args.kwargs["devices_found"] == 3
# --- guest-visibility advisory ---------------------------------------------
def test_advisory_when_hosts_only() -> None:
msg = _guest_visibility_advisory([_host_node()])
assert msg is not None
assert "PVEAuditor" in msg
def test_no_advisory_when_guests_present() -> None:
assert _guest_visibility_advisory([_host_node(), _guest_node(101, "10.0.0.5")]) is None
def test_no_advisory_when_nothing_imported() -> None:
assert _guest_visibility_advisory([]) is None
# --- persistence / dedupe --------------------------------------------------
@pytest.mark.asyncio
async def test_persist_creates_pending(db_session) -> None:
nodes = [_host_node(), _guest_node(101, "10.0.0.5")]
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
result = await _persist_pending_import(db_session, nodes, edges)
assert result.pending_created == 2
assert result.links_recorded == 1
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
# Specs carried as properties.
vm = next(r for r in rows if r.suggested_type == "vm")
assert any(p["key"] == "CPU Cores" for p in vm.properties)
@pytest.mark.asyncio
async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
# A device previously found by an IP scan (no ieee, no specs).
scanned = Node(
id=str(uuid.uuid4()), type="generic", label="10.0.0.5",
ip="10.0.0.5", status="online", pos_x=0, pos_y=0,
)
db_session.add(scanned)
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# No duplicate node; identity + specs merged onto the existing one.
nodes = (await db_session.execute(select(Node).where(Node.ip == "10.0.0.5"))).scalars().all()
assert len(nodes) == 1
merged = nodes[0]
assert merged.ieee_address == "pve-pve1-101"
assert merged.cpu_count == 2
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
# Inventory row exists as approved (already on canvas).
inv = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert inv.status == "approved"
@pytest.mark.asyncio
async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
# Device previously found by an IP scan: arp source, MAC known, no ieee.
db_session.add(PendingDevice(
id=str(uuid.uuid4()), ip="10.0.0.5", mac="bc:24:11:aa:bb:cc",
suggested_type="generic", status="pending",
discovery_source="arp", discovery_sources=["arp"],
))
await db_session.commit()
# Proxmox import of the same box (stopped VM → no IP) but same NIC MAC in a
# different casing. Must merge, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
assert len(rows) == 1
row = rows[0]
assert row.ieee_address == "pve-pve1-101" # adopted proxmox identity
assert row.suggested_type == "vm" # kept proxmox type
assert set(row.discovery_sources) == {"arp", "proxmox"} # shows in both filters
@pytest.mark.asyncio
async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -> None:
# Legacy inventory row from an old IP scan, before discovery_source(s) were
# recorded: scalar NULL, sources empty — but it has an IP + MAC.
db_session.add(PendingDevice(
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
suggested_type="lxc", status="pending",
discovery_source=None, discovery_sources=[],
))
await db_session.commit()
await _persist_pending_import(
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
)
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
assert len(rows) == 1
# The IP tag must survive the proxmox merge even with no recorded origin.
assert set(rows[0].discovery_sources) == {"arp", "proxmox"}
@pytest.mark.asyncio
async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -> None:
# The immich case: an on-canvas node from an old scan, with a legacy
# inventory row (NULL source). Merge must keep the IP tag on the row.
node = Node(
id=str(uuid.uuid4()), type="lxc", label="immich",
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
status="online", pos_x=0, pos_y=0,
)
inv = PendingDevice(
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
suggested_type="lxc", status="approved",
discovery_source=None, discovery_sources=[],
)
db_session.add_all([node, inv])
await db_session.commit()
await _persist_pending_import(
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
)
inv_row = (await db_session.execute(
select(PendingDevice).where(PendingDevice.mac == "bc:24:11:6c:96:52")
)).scalar_one()
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
@pytest.mark.asyncio
async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) -> None:
# A guest first seen via Proxmox (agent IP, pve ieee) must NOT gain a spurious
# IP tag on re-sync — it was never IP-scanned.
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], []) # re-sync
row = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101")
)).scalar_one()
assert set(row.discovery_sources) == {"proxmox"}
@pytest.mark.asyncio
async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
# A scanned canvas node with a MAC but no IP recorded for the guest.
scanned = Node(
id=str(uuid.uuid4()), type="generic", label="box",
mac="bc:24:11:aa:bb:cc", status="online", pos_x=0, pos_y=0,
)
db_session.add(scanned)
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
nodes = (await db_session.execute(select(Node))).scalars().all()
assert len(nodes) == 1 # no duplicate node
merged = nodes[0]
assert merged.ieee_address == "pve-pve1-101"
assert merged.mac == "bc:24:11:aa:bb:cc"
assert merged.cpu_count == 2 # specs backfilled
@pytest.mark.asyncio
async def test_persist_resync_updates_in_place(db_session) -> None:
nodes = [_guest_node(101, "10.0.0.5")]
await _persist_pending_import(db_session, nodes, [])
# Second sync: same device, new IP. Should update, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
rows = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
assert len(rows) == 1
assert rows[0].ip == "10.0.0.9"
@pytest.mark.asyncio
async def test_persist_keeps_hidden_hidden(db_session) -> None:
db_session.add(PendingDevice(
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
suggested_type="vm", status="hidden", discovery_source="proxmox",
))
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
row = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert row.status == "hidden"
@pytest.mark.asyncio
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
# Legacy row: properties column NULL (added by migration on older DBs).
dev = PendingDevice(
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
status="pending", discovery_source="arp",
)
dev.properties = None
db_session.add(dev)
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
assert res.json()[0]["properties"] == []
# --- cluster links ---------------------------------------------------------
@pytest.mark.asyncio
async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
# Two hosts + a guest → one host↔host cluster link, one host→guest link.
nodes = [
{**_host_node(), "id": "pve-node-a", "ieee_address": "pve-node-a", "hostname": "a", "label": "a"},
{**_host_node(), "id": "pve-node-b", "ieee_address": "pve-node-b", "hostname": "b", "label": "b"},
_guest_node(101, "10.0.0.5"),
]
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
await _persist_pending_import(db_session, nodes, edges)
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
assert len(cluster) == 1
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
# Membership helper sees both endpoints.
assert await _is_proxmox_cluster_member(db_session, "pve-node-a") is True
assert await _is_proxmox_cluster_member(db_session, "pve-node-b") is True
assert await _is_proxmox_cluster_member(db_session, "pve-pve1-101") is False
@pytest.mark.asyncio
async def test_single_host_records_no_cluster_link(db_session) -> None:
await _persist_pending_import(db_session, [_host_node()], [])
links = (await db_session.execute(
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
)).scalars().all()
assert links == []
@pytest.mark.asyncio
async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
# Two host nodes already on a canvas + a pending cluster link between them.
design = Design(id=str(uuid.uuid4()), name="d")
db_session.add(design)
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", ieee_address="pve-node-b",
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
db_session.add_all([a, b])
db_session.add(PendingDeviceLink(
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
discovery_source="proxmox_cluster",
))
await db_session.commit()
await _resolve_pending_links_for_ieee(db_session, "pve-node-a", design.id)
edge = (await db_session.execute(select(Edge))).scalars().one()
assert edge.type == "cluster"
assert edge.source_handle == "right"
# Bare side name (canonical) so React Flow resolves it to the left side;
# a '-t' target would fall back to the top handle.
assert edge.target_handle == "left"
@pytest.mark.asyncio
async def test_link_survives_and_resolves_onto_second_design(db_session) -> None:
# Regression: approving the same mesh devices onto a second canvas must also
# get their edges. The link row is topology, not one-shot — resolving it on
# design A must not consume it, so design B resolves too.
da = Design(id=str(uuid.uuid4()), name="a")
db_ = Design(id=str(uuid.uuid4()), name="b")
db_session.add_all([da, db_])
# Same two devices placed on BOTH designs (one Node per canvas).
for d in (da, db_):
db_session.add_all([
Node(id=str(uuid.uuid4()), type="iot", label="x", ieee_address="0xAAAA",
status="online", pos_x=0, pos_y=0, design_id=d.id),
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
status="online", pos_x=0, pos_y=0, design_id=d.id),
])
db_session.add(PendingDeviceLink(
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
discovery_source="zigbee",
))
await db_session.commit()
first = await _resolve_pending_links_for_ieee(db_session, "0xAAAA", da.id)
second = await _resolve_pending_links_for_ieee(db_session, "0xAAAA", db_.id)
assert len(first) == 1
assert len(second) == 1 # would be 0 if the link were consumed on design A
# One iot edge per design, each between that design's own nodes.
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 2
assert {e.design_id for e in edges} == {da.id, db_.id}
assert all(e.type == "iot" for e in edges)
# The link row is still present for any further design.
assert (await db_session.execute(select(PendingDeviceLink))).scalars().one()
@pytest.mark.asyncio
async def test_persist_never_deletes(db_session) -> None:
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# A later sync that no longer includes vm101 must not remove it.
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
ieees = {r.ieee_address for r in rows}
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees
-220
View File
@@ -1,220 +0,0 @@
"""Unit tests for the Proxmox VE import service (parsing, props, sanitizer)."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from app.services import proxmox_service as svc
def test_gb_conversion() -> None:
assert svc._gb(1024 ** 3) == 1.0
assert svc._gb(2 * 1024 ** 3) == 2.0
assert svc._gb(0) is None
assert svc._gb(None) is None
assert svc._gb("nope") is None
def test_extract_qemu_ip_skips_loopback() -> None:
payload = {
"result": [
{"name": "lo", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "127.0.0.1"}]},
{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "192.168.1.20"}]},
]
}
assert svc._extract_qemu_ip(payload) == "192.168.1.20"
assert svc._extract_qemu_ip(None) is None
assert svc._extract_qemu_ip({"result": []}) is None
def test_extract_lxc_ip_parses_net0_static() -> None:
cfg = {"net0": "name=eth0,bridge=vmbr0,ip=192.168.1.30/24,gw=192.168.1.1"}
assert svc._extract_lxc_ip(cfg) == "192.168.1.30"
# DHCP → no static IP
assert svc._extract_lxc_ip({"net0": "name=eth0,bridge=vmbr0,ip=dhcp"}) is None
assert svc._extract_lxc_ip(None) is None
def test_extract_net_mac_parses_qemu_and_lxc_forms() -> None:
# qemu: "virtio=<MAC>,bridge=.."
assert svc._extract_net_mac({"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0"}) == "bc:24:11:aa:bb:cc"
# lxc: "..,hwaddr=<MAC>,.."
assert (
svc._extract_net_mac({"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:11:22:33,ip=dhcp"})
== "bc:24:11:11:22:33"
)
# No MAC / missing net0 / None → None
assert svc._extract_net_mac({"net0": "name=eth0,ip=dhcp"}) is None
assert svc._extract_net_mac({}) is None
assert svc._extract_net_mac(None) is None
@pytest.mark.asyncio
async def test_fetch_inventory_captures_guest_mac_from_config() -> None:
"""Guests carry a normalized NIC MAC read agent-free from their config."""
async def fake_get_json(client, path: str):
if path == "/nodes":
return [{"node": "pve1", "status": "online", "maxcpu": 8}]
if path == "/nodes/pve1/qemu":
return [{"vmid": 101, "name": "web", "status": "stopped"}] # stopped: no agent IP
if path == "/nodes/pve1/lxc":
return [{"vmid": 200, "name": "db", "status": "running"}]
if path == "/nodes/pve1/qemu/101/config":
return {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0"}
if path == "/nodes/pve1/lxc/200/config":
return {"net0": "name=eth0,bridge=vmbr0,hwaddr=11:22:33:44:55:66,ip=10.0.0.6/24"}
return None
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
nodes, _ = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
vm = next(n for n in nodes if n["type"] == "vm")
assert vm["mac"] == "aa:bb:cc:dd:ee:ff" # captured even though VM is stopped (no IP)
assert vm["ip"] is None
ct = next(n for n in nodes if n["type"] == "lxc")
assert ct["mac"] == "11:22:33:44:55:66"
assert ct["ip"] == "10.0.0.6"
def test_host_and_guest_node_mapping() -> None:
host = svc._host_node({"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3, "maxdisk": 500 * 1024 ** 3})
assert host["type"] == "proxmox"
assert host["ieee_address"] == "pve-node-pve1"
assert host["cpu_count"] == 8
assert host["ram_gb"] == 16.0
assert host["status"] == "online"
assert host["parent_ieee"] is None
vm = svc._guest_node({"vmid": 101, "name": "web", "status": "running", "maxcpu": 2, "maxmem": 2 * 1024 ** 3, "maxdisk": 32 * 1024 ** 3}, "pve1", "qemu", "10.0.0.5")
assert vm["type"] == "vm"
assert vm["ieee_address"] == "pve-pve1-101"
assert vm["ip"] == "10.0.0.5"
assert vm["status"] == "online"
assert vm["parent_ieee"] == "pve-node-pve1"
ct = svc._guest_node({"vmid": 200, "name": "db", "status": "stopped"}, "pve1", "lxc", None)
assert ct["type"] == "lxc"
assert ct["status"] == "offline"
def test_build_properties_includes_specs() -> None:
node = {"vmid": 101, "model": "QEMU", "cpu_count": 4, "ram_gb": 8.0, "disk_gb": 40.0}
props = svc.build_proxmox_properties(node)
keys = {p["key"] for p in props}
assert {"VMID", "CPU Cores", "RAM", "Disk", "Source"} <= keys
assert all(p["visible"] is False for p in props)
def test_parse_inventory_builds_host_guest_edges() -> None:
hosts = [{"node": "pve1", "status": "online", "maxcpu": 4}]
guests = {"pve1": [svc._guest_node({"vmid": 101, "name": "web", "status": "running"}, "pve1", "qemu", None)]}
nodes, edges = svc._parse_inventory(hosts, guests)
assert len(nodes) == 2
assert edges == [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
def test_build_cluster_links_chains_hosts() -> None:
nodes = [
svc._host_node({"node": "pve-a", "status": "online"}),
svc._guest_node({"vmid": 101, "status": "running"}, "pve-a", "qemu", None),
svc._host_node({"node": "pve-b", "status": "online"}),
svc._host_node({"node": "pve-c", "status": "online"}),
]
pairs = svc.build_proxmox_cluster_links(nodes)
assert pairs == [("pve-node-pve-a", "pve-node-pve-b"), ("pve-node-pve-b", "pve-node-pve-c")]
def test_build_cluster_links_single_host_is_not_a_cluster() -> None:
nodes = [svc._host_node({"node": "pve-a", "status": "online"})]
assert svc.build_proxmox_cluster_links(nodes) == []
# Guests alone never form a cluster.
guest = svc._guest_node({"vmid": 1, "status": "running"}, "pve-a", "qemu", None)
assert svc.build_proxmox_cluster_links([guest]) == []
def test_sanitize_error_hides_credentials() -> None:
exc = httpx.HTTPStatusError(
"boom", request=httpx.Request("GET", "https://h/api2/json"),
response=httpx.Response(401),
)
msg = svc._sanitize_proxmox_error(exc)
assert "token" not in msg.lower() or "check the api token" in msg.lower()
assert "Authentication failed" in msg
@pytest.mark.asyncio
async def test_fetch_inventory_happy_path() -> None:
async def fake_get_json(client, path: str):
if path == "/nodes":
return [{"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3}]
if path == "/nodes/pve1/qemu":
return [{"vmid": 101, "name": "web", "status": "running", "maxmem": 2 * 1024 ** 3}]
if path == "/nodes/pve1/lxc":
return [{"vmid": 200, "name": "db", "status": "stopped"}]
if path.endswith("/agent/network-get-interfaces"):
return {"result": [{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "10.0.0.5"}]}]}
if path.endswith("/config"):
return {"net0": "name=eth0,ip=10.0.0.6/24"}
return None
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
nodes, edges = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
by_type = {n["type"] for n in nodes}
assert by_type == {"proxmox", "vm", "lxc"}
vm = next(n for n in nodes if n["type"] == "vm")
assert vm["ip"] == "10.0.0.5"
ct = next(n for n in nodes if n["type"] == "lxc")
assert ct["ip"] == "10.0.0.6"
assert len(edges) == 2
@pytest.mark.asyncio
async def test_test_connection_returns_message() -> None:
async def fake_get_json(client, path: str):
if path == "/access/permissions":
return {"/": {"VM.Audit": 1}} # token has an ACL
return {"version": "8.2.2"}
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
assert ok is True
assert "8.2.2" in msg
assert "warning" not in msg.lower()
@pytest.mark.asyncio
async def test_test_connection_warns_when_token_has_no_permissions() -> None:
async def fake_get_json(client, path: str):
if path == "/access/permissions":
return {} # privilege-separated token with no effective ACL
return {"version": "8.4.19"}
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
# Auth still succeeds; the message flags the permission gap.
assert ok is True
assert "8.4.19" in msg
assert "PVEAuditor" in msg
@pytest.mark.asyncio
async def test_token_has_permissions_treats_empty_as_no_perms() -> None:
async def fake_get_json(client, path: str):
return {}
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
assert await svc._token_has_permissions(object()) is False
@pytest.mark.asyncio
async def test_token_has_permissions_assumes_ok_on_error() -> None:
async def boom(client, path: str):
raise httpx.ConnectError("nope")
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=boom)):
# Never block a valid import on a permissions-probe failure.
assert await svc._token_has_permissions(object()) is True
+239
View File
@@ -0,0 +1,239 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice, ScanRun
from app.services.scanner import run_scan
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
async def pending_device(db_session):
import uuid
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.100",
mac="aa:bb:cc:dd:ee:ff",
hostname="my-server",
os="Linux",
services=[{"port": 22, "name": "ssh"}],
suggested_type="server",
status="pending",
)
db_session.add(device)
await db_session.commit()
await db_session.refresh(device)
return device
# --- Trigger scan ---
@pytest.mark.asyncio
async def test_trigger_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/trigger")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
with (
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
patch("app.api.routes.scan.settings") as mock_settings,
):
mock_settings.scanner_ranges = ["192.168.1.0/24"]
res = await client.post("/api/v1/scan/trigger", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["status"] == "running"
assert data["ranges"] == ["192.168.1.0/24"]
assert "id" in data
# --- Pending devices ---
@pytest.mark.asyncio
async def test_list_pending_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
assert res.json() == []
@pytest.mark.asyncio
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
data = res.json()
assert len(data) == 1
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["hostname"] == "my-server"
# --- Approve device ---
@pytest.mark.asyncio
async def test_approve_device(client: AsyncClient, headers, pending_device):
node_payload = {
"label": "My Server",
"type": "server",
"ip": "192.168.1.100",
"hostname": "my-server",
"status": "unknown",
"services": [],
}
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json=node_payload,
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert "node_id" in data
# Device should no longer appear in pending list
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
@pytest.mark.asyncio
async def test_approve_nonexistent_device(client: AsyncClient, headers):
node_payload = {
"label": "Ghost",
"type": "generic",
"ip": "10.0.0.1",
"status": "unknown",
"services": [],
}
res = await client.post(
"/api/v1/scan/pending/nonexistent-id/approve",
json=node_payload,
headers=headers,
)
assert res.status_code == 200
assert res.json()["approved"] is False
# --- Hide device ---
@pytest.mark.asyncio
async def test_hide_device(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
assert res.status_code == 200
assert res.json()["hidden"] is True
# Should no longer appear in pending
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
# Should appear in hidden
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 1
# --- Ignore device ---
@pytest.mark.asyncio
async def test_ignore_device(client: AsyncClient, headers, pending_device):
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
assert res.status_code == 200
assert res.json()["ignored"] is True
# Device should be gone from both pending and hidden
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert hidden_res.json() == []
# --- Scan runs ---
@pytest.mark.asyncio
async def test_list_runs_empty(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/runs", headers=headers)
assert res.status_code == 200
assert res.json() == []
# --- run_scan: re-scan updates existing pending devices ---
MOCK_HOST = {
"ip": "192.168.1.50",
"mac": "aa:bb:cc:dd:ee:ff",
"hostname": "myhost.lan",
"os": "Linux",
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
}
@pytest.mark.asyncio
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
assert device.suggested_type == "server"
@pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate."""
# Pre-existing pending device with no services
existing = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(existing)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# Should still be only one device
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
devices = list(result.scalars().all())
assert len(devices) == 1
device = devices[0]
# Services and hostname should be updated
assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services)
-976
View File
@@ -1,976 +0,0 @@
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.database import Base
from app.db.models import Node, PendingDevice, ScanRun
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_id() -> str:
return str(uuid.uuid4())
@pytest.fixture
async def mem_db():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
yield factory
await engine.dispose()
def _make_scan_run(run_id: str) -> ScanRun:
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
# ---------------------------------------------------------------------------
# _ping_sweep
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ping_sweep_returns_alive_hosts():
from app.services.scanner import _ping_sweep
async def fake_ping(ip: str) -> str | None:
return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
with patch("app.services.scanner._ping_sweep", wraps=None):
pass # just ensure import is fine
# Patch asyncio.create_subprocess_exec to simulate ping responses
responding = {"192.168.1.1", "192.168.1.2"}
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip in responding else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
assert "192.168.1.1" in result
assert "192.168.1.2" in result
for host in result.values():
assert host["open_ports"] == []
@pytest.mark.asyncio
async def test_ping_sweep_excludes_non_responding():
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert "192.168.1.1" in result
assert "192.168.1.2" not in result
@pytest.mark.asyncio
async def test_ping_sweep_supplements_with_arp_cache():
"""Devices that block ICMP but appear in ARP cache should still be discovered."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
proc = MagicMock()
proc.returncode = 1 # all pings fail
proc.wait = AsyncMock(return_value=1)
return proc
arp_extra = {
"192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/24")
assert "192.168.1.10" in result
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
@pytest.mark.asyncio
async def test_ping_sweep_enriches_mac_from_arp_cache():
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
arp_extra = {
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
# ---------------------------------------------------------------------------
# _arp_table_hosts
# ---------------------------------------------------------------------------
def test_arp_table_hosts_parses_proc_net_arp():
import io # noqa: PLC0415
from app.services.scanner import _arp_table_hosts
arp_content = (
"IP address HW type Flags HW address Mask Device\n"
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
)
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
mock_file.__exit__ = MagicMock(return_value=False)
with patch("builtins.open", return_value=mock_file), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.50" in result
assert "10.0.0.1" not in result # outside target subnet
assert "192.168.1.99" not in result # zero MAC skipped
def test_arp_table_hosts_parses_macos_arp_output():
from app.services.scanner import _arp_table_hosts
arp_output = (
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
)
mock_result = MagicMock()
mock_result.stdout = arp_output
with patch("builtins.open", side_effect=FileNotFoundError), \
patch("subprocess.run", return_value=mock_result), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.20" in result
assert "192.168.1.99" not in result # incomplete MAC
assert "10.0.0.1" not in result # outside subnet
# ---------------------------------------------------------------------------
# _nmap_scan_single (Phase 2 per-IP worker)
# ---------------------------------------------------------------------------
def test_nmap_scan_single_detects_open_ports():
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
# Build a realistic host entry: protocols → ports → port info
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
mock_host = MagicMock()
mock_host.all_protocols.return_value = ["tcp"]
mock_host.__getitem__ = MagicMock(return_value=port_info)
mock_host.get.return_value = {}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.10"]
mock_nm.__getitem__ = MagicMock(return_value=mock_host)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
assert len(result["open_ports"]) == 1
assert result["open_ports"][0]["port"] == 80
assert result["open_ports"][0]["banner"] == "nginx 1.24"
def test_nmap_scan_single_returns_host_unchanged_on_error():
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}
mock_nm = MagicMock()
mock_nm.scan.side_effect = Exception("nmap error")
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_scan_single(host)
assert result["ip"] == "192.168.1.20"
assert result["open_ports"] == []
def test_nmap_scan_single_returns_host_unchanged_when_no_results():
"""Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = [] # no results
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_scan_single(host)
assert result["ip"] == "192.168.1.30"
assert result["open_ports"] == []
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
# ---------------------------------------------------------------------------
# _nmap_scan_single — two-pass discovery/version split (issue #277)
# ---------------------------------------------------------------------------
def _fake_scanner(ip, port_info, mac=None):
"""Mock nmap.PortScanner whose results contain `ip` with tcp `port_info`."""
host = MagicMock()
host.all_protocols.return_value = ["tcp"]
host.__getitem__ = MagicMock(return_value=port_info)
host.get.return_value = {"mac": mac} if mac else {}
nm = MagicMock()
nm.all_hosts.return_value = [ip]
nm.__getitem__ = MagicMock(return_value=host)
return nm
def _empty_scanner():
nm = MagicMock()
nm.all_hosts.return_value = []
return nm
def _failing_scanner(exc=Exception("host timeout")):
nm = MagicMock()
nm.scan.side_effect = exc
return nm
def test_nmap_scan_single_two_pass_merges_banners():
"""Pass A discovers ports; Pass B enriches them with -sV banners."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.10", {22: {"state": "open"}, 8006: {"state": "open"}})
ver = _fake_scanner("192.168.1.10", {
22: {"state": "open", "product": "OpenSSH", "version": "9.0"},
8006: {"state": "open", "product": "", "version": ""},
})
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=0), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
banners = {p["port"]: p["banner"] for p in result["open_ports"]}
assert banners == {22: "OpenSSH 9.0", 8006: ""}
# Pass A: discovery only, no -sV / host-timeout. Pass B: -sV, bounded.
disc_args = disc.scan.call_args.kwargs["arguments"]
ver_args = ver.scan.call_args.kwargs["arguments"]
assert "-sV" not in disc_args and "--host-timeout" not in disc_args
assert "-sV" in ver_args and "--host-timeout 60s" in ver_args
assert ver_args.endswith("-p 22,8006") # version pass scoped to found ports
def test_nmap_scan_single_keeps_ports_when_version_pass_fails():
"""Regression #277: a stalling version pass must not drop discovered ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.100.3", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.100.3", {22: {"state": "open"}, 8006: {"state": "open"}})
ver = _failing_scanner() # -sV blows past --host-timeout on the TLS port
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=0):
result = _nmap_scan_single(host)
ports = {p["port"] for p in result["open_ports"]}
assert ports == {22, 8006} # both survive despite the version failure
assert all(p["banner"] == "" for p in result["open_ports"])
def test_nmap_scan_single_keeps_ports_when_version_pass_empty():
"""Version pass returns no results for the host — keep the discovered ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.11", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.11", {443: {"state": "open"}})
ver = _empty_scanner()
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=0):
result = _nmap_scan_single(host)
assert [p["port"] for p in result["open_ports"]] == [443]
assert result["open_ports"][0]["banner"] == ""
def test_nmap_scan_single_no_open_ports_skips_version_pass():
"""Host reachable but nothing open — no version pass, empty ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.12", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.12", {80: {"state": "filtered"}})
# Only one PortScanner instance may be created (Pass B must be skipped);
# a second would raise StopIteration from side_effect.
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
patch("app.services.scanner.os.geteuid", return_value=0):
result = _nmap_scan_single(host)
assert result["open_ports"] == []
def test_nmap_scan_single_non_root_uses_connect_scan():
"""Without root, both passes use -sT (connect) instead of -sS (SYN)."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.13", "hostname": None, "mac": None, "os": None, "open_ports": []}
disc = _fake_scanner("192.168.1.13", {80: {"state": "open"}})
ver = _fake_scanner("192.168.1.13", {80: {"state": "open", "product": "nginx", "version": "1.24"}})
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
patch("app.services.scanner.os.geteuid", return_value=1000), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
assert disc.scan.call_args.kwargs["arguments"].startswith("-sT")
assert ver.scan.call_args.kwargs["arguments"].startswith("-sT")
assert result["open_ports"][0]["banner"] == "nginx 1.24"
# ---------------------------------------------------------------------------
# _nmap_scan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_scan_uses_mock_when_nmap_unavailable():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._NMAP_AVAILABLE", False):
result = await _nmap_scan("192.168.1.0/24")
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.99"
@pytest.mark.asyncio
async def test_nmap_scan_raises_on_sweep_error():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
pytest.raises(RuntimeError, match="ping sweep failed"):
await _nmap_scan("192.168.1.0/24")
# ---------------------------------------------------------------------------
# Cancellation responsiveness (issue #218)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_scan_cancelled_before_start_skips_phases():
"""A run already cancelled returns immediately without touching the network."""
from app.services.scanner import _cancelled_runs, _nmap_scan, request_cancel
run_id = "cancel-before-start"
request_cancel(run_id)
try:
with patch("app.services.scanner._ping_sweep", new_callable=AsyncMock) as mock_sweep, \
patch("app.services.scanner._nmap_port_scan", new_callable=AsyncMock) as mock_port:
result = await _nmap_scan("192.168.1.0/24", run_id=run_id)
assert result == []
mock_sweep.assert_not_called()
mock_port.assert_not_called()
finally:
_cancelled_runs.discard(run_id)
@pytest.mark.asyncio
async def test_ping_sweep_cancelled_mid_sweep_returns_empty():
"""Cancelling during Phase 1 bails before Phase 2 — no alive hosts returned."""
from app.services.scanner import _cancelled_runs, _ping_sweep, request_cancel
run_id = "cancel-during-sweep"
async def _fake_subprocess(*args, **kwargs):
proc = AsyncMock()
proc.wait = AsyncMock(return_value=1)
proc.returncode = 1
return proc
request_cancel(run_id)
try:
with patch("app.services.scanner.asyncio.create_subprocess_exec", new=_fake_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}):
result = await _ping_sweep("192.168.1.0/30", run_id=run_id)
assert result == {}
finally:
_cancelled_runs.discard(run_id)
@pytest.mark.asyncio
async def test_nmap_port_scan_skips_queued_hosts_when_cancelled():
"""Once cancelled, queued hosts return unscanned instead of invoking nmap."""
from app.services.scanner import _cancelled_runs, _nmap_port_scan, request_cancel
run_id = "cancel-port-scan"
alive = {
"192.168.1.10": {
"ip": "192.168.1.10", "mac": None, "hostname": None,
"os": None, "open_ports": [],
},
}
request_cancel(run_id)
try:
with patch("app.services.scanner._nmap_scan_single") as mock_single:
result = await _nmap_port_scan(alive, run_id=run_id)
mock_single.assert_not_called()
assert result[0]["ip"] == "192.168.1.10"
assert result[0]["open_ports"] == []
finally:
_cancelled_runs.discard(run_id)
# ---------------------------------------------------------------------------
# _mdns_discover
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
from app.services.scanner import _mdns_discover
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
result = await _mdns_discover()
assert result == []
@pytest.mark.asyncio
async def test_mdns_discover_returns_devices():
from app.services.scanner import _mdns_discover
mock_info = MagicMock()
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
mock_info.server = "shelly1.local."
mock_info.port = 80
mock_info.async_request = AsyncMock(return_value=True)
mock_browser = AsyncMock()
mock_browser.async_cancel = AsyncMock()
# Simulate a service being found during the sleep
captured_handler: list = []
def fake_browser(zc, types, handlers):
captured_handler.extend(handlers)
return mock_browser
from zeroconf import ServiceStateChange
async def fake_sleep(t):
# Fire the handler as if a device was discovered
for h in captured_handler:
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
mock_azc = AsyncMock()
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
mock_azc.__aexit__ = AsyncMock(return_value=None)
mock_azc.zeroconf = MagicMock()
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
patch("asyncio.sleep", side_effect=fake_sleep):
result = await _mdns_discover(timeout=0.01)
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.80"
assert result[0]["hostname"] == "shelly1.local."
# ---------------------------------------------------------------------------
# _nmap_port_scan (Phase 2 concurrency)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
from app.services.scanner import _nmap_port_scan
result = await _nmap_port_scan({})
assert result == []
@pytest.mark.asyncio
async def test_nmap_port_scan_tolerates_single_host_exception():
"""A single per-host failure should not abort the entire Phase 2 gather."""
from app.services.scanner import _nmap_port_scan
hosts = {
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
call_count = 0
def _flaky_scan(host_dict, port_spec=None):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
raise RuntimeError("simulated nmap crash")
return host_dict
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
patch("app.services.scanner._NMAP_AVAILABLE", True):
result = await _nmap_port_scan(hosts)
assert call_count == 2
# The crashing host is dropped; the healthy one survives
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.2"
# ---------------------------------------------------------------------------
# run_scan integration
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice))
devices = result.scalars().all()
assert any(d.ip == "192.168.1.5" for d in devices)
@pytest.mark.asyncio
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n1")
assert node is not None
assert node.last_scan is not None
@pytest.mark.asyncio
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
"""A node with no IP but a matching MAC still gets last_scan stamped."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n2")
assert node is not None
assert node.last_scan is not None
@pytest.mark.asyncio
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n3")
assert node is not None
assert node.last_scan is None
@pytest.mark.asyncio
async def test_run_scan_mdns_only_device_added(mem_db):
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
assert device.discovery_source == "mdns"
@pytest.mark.asyncio
async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
"""A scan reconciles a prior Proxmox-imported row by MAC: fills the IP,
unions the source, keeps the vm type, and does not duplicate."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
session.add(PendingDevice(
id="pve-row", ieee_address="pve-pve1-101", ip=None,
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
discovery_source="proxmox", discovery_sources=["proxmox"],
))
await session.commit()
# Scan sees the same box (same MAC, different casing) with a live IP.
nmap_hosts = [{"ip": "192.168.1.50", "hostname": "web.lan",
"mac": "BC:24:11:AA:BB:CC", "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
assert len(rows) == 1 # merged, not duplicated
row = rows[0]
assert row.ip == "192.168.1.50" # scan filled the IP
assert row.mac == "bc:24:11:aa:bb:cc" # normalized
assert row.suggested_type == "vm" # kept proxmox type
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
@pytest.mark.asyncio
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
devices = result.scalars().all()
assert len(devices) == 1 # not duplicated
@pytest.mark.asyncio
async def test_run_scan_keeps_canvas_nodes(mem_db):
"""Hosts already on a canvas are NOT suppressed — they stay in the inventory
(badged "In N canvas" via correlation), so a re-scan still records them."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
canvas_node = Node(
id=str(uuid.uuid4()), label="PVE", type="proxmox",
ip="192.168.1.100", status="online",
)
session.add(canvas_node)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_devices(mem_db):
"""Hosts hidden by the user must not re-appear in pending."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
session.add(hidden)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
from app.services.scanner import request_cancel, run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
request_cancel(run_id)
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
run = await session.get(ScanRun, run_id)
assert run is not None
assert run.status == "cancelled"
# ---------------------------------------------------------------------------
# Deep scan: port-range plumbing + HTTP probe
# ---------------------------------------------------------------------------
def test_valid_port_range():
from app.services.scanner import _valid_port_range
assert _valid_port_range("8080")
assert _valid_port_range("8000-8100")
assert not _valid_port_range("8100-8000") # reversed
assert not _valid_port_range("0") # below 1
assert not _valid_port_range("70000") # above 65535
assert not _valid_port_range("abc")
assert not _valid_port_range("80,443") # not a single range
def test_build_port_spec_default_when_empty():
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
assert _build_port_spec([]) == _EXTRA_PORTS
assert _build_port_spec(None) == _EXTRA_PORTS
def test_build_port_spec_appends_valid_ranges():
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
spec = _build_port_spec(["8000-8100", "9000"])
assert spec == _EXTRA_PORTS + ",8000-8100,9000"
def test_build_port_spec_drops_invalid_ranges():
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
# invalid entries silently dropped; only valid kept
assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS
assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000"
@pytest.mark.asyncio
async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db):
from app.services.scanner import DeepScanOptions, run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
captured = {}
async def fake_nmap(target, port_spec, run_id=None):
captured["port_spec"] = port_spec
return []
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", new=fake_nmap), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(
["192.168.1.0/24"], session, run_id,
deep_scan=DeepScanOptions(http_ranges=["8000-8100"]),
)
assert "8000-8100" in captured["port_spec"]
@pytest.mark.asyncio
async def test_run_scan_probe_enriches_services(mem_db):
"""With probe enabled, a custom-port service is identified via HTTP signals."""
from app.services.scanner import DeepScanOptions, run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
nmap_hosts = [{
"ip": "192.168.1.50", "hostname": None, "mac": None, "os": None,
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
}]
jellyfin_sig = [{
"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server",
}]
async def fake_probe(ip, ports, verify_tls=False, concurrency=50):
return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.services.scanner.probe_open_ports", new=fake_probe), \
patch("app.services.fingerprint._load", return_value=jellyfin_sig), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(
["192.168.1.0/24"], session, run_id,
deep_scan=DeepScanOptions(http_probe_enabled=True),
)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
device = result.scalar_one_or_none()
assert device is not None
assert any(s["service_name"] == "Jellyfin" for s in device.services)
@pytest.mark.asyncio
async def test_run_scan_no_probe_when_disabled(mem_db):
"""Probe must not be called on a standard (non-deep) scan."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
nmap_hosts = [{
"ip": "192.168.1.51", "hostname": None, "mac": None, "os": None,
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
}]
probe = AsyncMock()
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.services.scanner.probe_open_ports", new=probe), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
probe.assert_not_called()
+2 -395
View File
@@ -5,24 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import (
_run_proxmox_sync,
_run_service_checks,
_run_status_checks,
_run_zigbee_sync,
_run_zwave_sync,
reschedule_proxmox_sync,
reschedule_service_checks,
reschedule_status_checks,
reschedule_zigbee_sync,
reschedule_zwave_sync,
set_proxmox_sync_enabled,
set_service_checks_enabled,
set_zigbee_sync_enabled,
set_zwave_sync_enabled,
start_scheduler,
stop_scheduler,
)
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
from app.db.database import Base
from app.db.models import Node
@@ -158,10 +141,6 @@ def test_scheduler_uses_settings_interval():
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
_, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45
@@ -170,381 +149,9 @@ def test_scheduler_uses_settings_interval():
def test_start_and_stop_scheduler():
"""Scheduler can be started and stopped without errors."""
mock_sched = MagicMock()
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
start_scheduler()
stop_scheduler()
mock_sched.add_job.assert_called_once()
mock_sched.start.assert_called_once()
mock_sched.shutdown.assert_called_once()
# ---------------------------------------------------------------------------
# Service checks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_service_checks_disabled_does_nothing(mem_db):
async with mem_db() as session:
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = False
await _run_service_checks()
mock_cs.assert_not_called()
@pytest.mark.asyncio
async def test_run_service_checks_broadcasts_per_node(mem_db):
async with mem_db() as session:
node = _make_node(
ip="10.0.0.5",
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
)
session.add(node)
await session.commit()
node_id = node.id
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_bcast.assert_awaited_once()
_, kwargs = mock_bcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["services"] == statuses
@pytest.mark.asyncio
async def test_run_service_checks_skips_nodes_without_services(mem_db):
async with mem_db() as session:
session.add(_make_node(ip="10.0.0.6", services=[]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
mock_settings.service_check_enabled = True
await _run_service_checks()
mock_cs.assert_not_called()
def test_set_service_checks_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.service_check_interval = 300
# Enable: no existing job -> add
mock_sched.get_job.return_value = None
set_service_checks_enabled(True)
mock_sched.add_job.assert_called_once()
# Disable: existing job -> remove
mock_sched.get_job.return_value = MagicMock()
set_service_checks_enabled(False)
mock_sched.remove_job.assert_called_once_with("service_checks")
def test_start_scheduler_adds_service_job_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = True
mock_settings.service_check_interval = 300
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids
assert "service_checks" in job_ids
# ---------------------------------------------------------------------------
# Proxmox auto-sync job
# ---------------------------------------------------------------------------
def test_set_proxmox_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.proxmox_sync_interval = 3600
mock_sched.get_job.return_value = None
set_proxmox_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_proxmox_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("proxmox_sync")
def test_reschedule_proxmox_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_proxmox_sync(60)
@pytest.mark.asyncio
async def test_run_proxmox_sync_skips_when_disabled():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.proxmox_sync_enabled = False
# Must return before importing/fetching anything.
await _run_proxmox_sync()
@pytest.mark.asyncio
async def test_run_proxmox_sync_skips_when_no_token():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.proxmox_sync_enabled = True
mock_settings.proxmox_host = "pve"
mock_settings.proxmox_token_id = ""
mock_settings.proxmox_token_secret = ""
await _run_proxmox_sync() # no exception, no fetch
def _proxmox_settings(mock_settings):
mock_settings.proxmox_sync_enabled = True
mock_settings.proxmox_host = "pve"
mock_settings.proxmox_port = 8006
mock_settings.proxmox_token_id = "root@pam!tok"
mock_settings.proxmox_token_secret = "secret"
mock_settings.proxmox_verify_tls = False
@pytest.mark.asyncio
async def test_run_proxmox_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=proxmox) so it shows in Scan
history, then delegate to the shared background import with the run id."""
from app.db.models import ScanRun
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch(
"app.api.routes.proxmox._background_proxmox_import",
new_callable=AsyncMock,
) as mock_bg,
):
_proxmox_settings(mock_settings)
await _run_proxmox_sync()
# A ScanRun row exists — the missing scan-history trace.
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "proxmox"
assert run.ranges == ["pve:8006"]
# Delegated to the same background flow the manual /sync-now uses, passing
# the run id + env connection settings.
mock_bg.assert_awaited_once_with(
run.id, "pve", 8006, "root@pam!tok", "secret", False,
)
# ---------------------------------------------------------------------------
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
# ---------------------------------------------------------------------------
def test_set_zigbee_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_interval = 3600
mock_sched.get_job.return_value = None
set_zigbee_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_zigbee_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
def test_set_zwave_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zwave_sync_interval = 3600
mock_sched.get_job.return_value = None
set_zwave_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_zwave_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("zwave_sync")
def test_reschedule_zigbee_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_zigbee_sync(60)
def test_reschedule_zwave_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_zwave_sync(60)
@pytest.mark.asyncio
async def test_run_zigbee_sync_skips_when_disabled():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_enabled = False
await _run_zigbee_sync() # returns before importing/fetching anything
@pytest.mark.asyncio
async def test_run_zigbee_sync_skips_when_no_host():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_mqtt_host = ""
await _run_zigbee_sync() # no exception, no fetch
@pytest.mark.asyncio
async def test_run_zwave_sync_skips_when_no_host():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_mqtt_host = ""
await _run_zwave_sync()
@pytest.mark.asyncio
async def test_run_zigbee_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
then delegate to the shared background import with the run id + env payload."""
from app.db.models import ScanRun
from app.schemas.zigbee import ZigbeeImportRequest
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
):
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_mqtt_host = "broker"
mock_settings.zigbee_mqtt_port = 1883
await _run_zigbee_sync()
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "zigbee"
assert run.ranges == ["broker:1883"]
mock_bg.assert_awaited_once_with(run.id, fake_payload)
@pytest.mark.asyncio
async def test_run_zwave_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
background import with the run id + env payload."""
from app.db.models import ScanRun
from app.schemas.zwave import ZwaveImportRequest
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
):
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_mqtt_host = "broker"
mock_settings.zwave_mqtt_port = 1883
await _run_zwave_sync()
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "zwave"
assert run.ranges == ["broker:1883"]
mock_bg.assert_awaited_once_with(run.id, fake_payload)
def test_reschedule_zigbee_sync_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_zigbee_sync(600)
mock_sched.reschedule_job.assert_not_called()
def test_start_scheduler_adds_mesh_jobs_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_sync_interval = 3600
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_sync_interval = 3600
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "zigbee_sync" in job_ids
assert "zwave_sync" in job_ids
# --- reschedule_* validation and not-running guards ---
def test_reschedule_status_checks_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_status_checks(5)
def test_reschedule_status_checks_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_status_checks(60)
mock_sched.reschedule_job.assert_not_called()
def test_reschedule_status_checks_updates_running_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_status_checks(60)
mock_sched.reschedule_job.assert_called_once_with(
"status_checks", trigger="interval", seconds=60,
)
def test_reschedule_service_checks_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_service_checks(10)
def test_reschedule_service_checks_updates_when_job_exists():
mock_sched = MagicMock()
mock_sched.running = True
mock_sched.get_job.return_value = MagicMock()
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_service_checks(60)
mock_sched.reschedule_job.assert_called_once_with(
"service_checks", trigger="interval", seconds=60,
)
def test_reschedule_proxmox_sync_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_proxmox_sync(600)
mock_sched.reschedule_job.assert_not_called()
-169
View File
@@ -1,169 +0,0 @@
"""Tests for GET/POST /api/v1/settings."""
import json
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from app.core.config import Settings
@pytest.mark.asyncio
async def test_get_settings_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/settings")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_interval(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
assert res.status_code == 200
data = res.json()
assert "interval_seconds" in data
assert isinstance(data["interval_seconds"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_interval(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.status_checker_interval = 60
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 120},
headers=headers,
)
assert res.status_code == 200
assert res.json()["interval_seconds"] == 120
@pytest.mark.asyncio
async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
data = res.json()
assert "service_check_enabled" in data
assert "service_check_interval" in data
assert isinstance(data["service_check_enabled"], bool)
assert isinstance(data["service_check_interval"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={
"interval_seconds": 60,
"service_check_enabled": True,
"service_check_interval": 600,
},
headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["service_check_enabled"] is True
assert body["service_check_interval"] == 600
@pytest.mark.asyncio
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
headers=headers,
)
assert res.status_code == 422
def test_proxmox_connection_config_is_env_only_never_from_overrides(tmp_path):
"""Connection config (host/port/verify_tls) is env-only: a stale value in
scan_config.json must be ignored so it can never clobber the env. Only the
auto-sync activation is read back."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.proxmox_host = "pve.local" # as if set from env
s.proxmox_port = 8006
s.proxmox_verify_tls = True
(tmp_path / "scan_config.json").write_text(json.dumps({
"proxmox_host": "stale-host",
"proxmox_port": 9999,
"proxmox_verify_tls": False,
"proxmox_sync_enabled": True,
"proxmox_sync_interval": 600,
}))
s.load_overrides()
# Connection config untouched by the file (env values survive).
assert s.proxmox_host == "pve.local"
assert s.proxmox_port == 8006
assert s.proxmox_verify_tls is True
# Auto-sync activation is the only thing loaded.
assert s.proxmox_sync_enabled is True
assert s.proxmox_sync_interval == 600
def test_save_overrides_omits_proxmox_connection_config(tmp_path):
"""save_overrides must never write host/port/verify_tls (nor the token) —
only the sync activation. This is what prevents the dual source of truth."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.proxmox_host = "pve.local"
s.proxmox_sync_enabled = True
s.proxmox_sync_interval = 900
s.save_overrides()
written = json.loads((tmp_path / "scan_config.json").read_text())
assert "proxmox_host" not in written
assert "proxmox_port" not in written
assert "proxmox_verify_tls" not in written
assert "proxmox_token_id" not in written
assert "proxmox_token_secret" not in written
assert written["proxmox_sync_enabled"] is True
assert written["proxmox_sync_interval"] == 900
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
env-only: stale values in scan_config.json must be ignored. Only the
auto-sync activation is read back."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.zigbee_mqtt_host = "broker.local"
s.zwave_mqtt_host = "broker.local"
(tmp_path / "scan_config.json").write_text(json.dumps({
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
}))
s.load_overrides()
assert s.zigbee_mqtt_host == "broker.local"
assert s.zwave_mqtt_host == "broker.local"
assert s.zigbee_sync_enabled is True
assert s.zigbee_sync_interval == 600
assert s.zwave_sync_enabled is True
assert s.zwave_sync_interval == 700
def test_save_overrides_omits_mesh_credentials(tmp_path):
"""save_overrides must never write MQTT host/credentials — only the sync
activation. Prevents the dual source of truth and leaking secrets to disk."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.zigbee_mqtt_host = "broker.local"
s.zigbee_mqtt_password = "secret"
s.zigbee_sync_enabled = True
s.zigbee_sync_interval = 900
s.zwave_mqtt_password = "secret"
s.zwave_sync_interval = 1200
s.save_overrides()
raw = (tmp_path / "scan_config.json").read_text()
assert "secret" not in raw
written = json.loads(raw)
assert "zigbee_mqtt_host" not in written
assert "zigbee_mqtt_password" not in written
assert "zwave_mqtt_password" not in written
assert written["zigbee_sync_enabled"] is True
assert written["zigbee_sync_interval"] == 900
assert written["zwave_sync_interval"] == 1200
-63
View File
@@ -1,63 +0,0 @@
"""Integrity + matching tests against the real service_signatures.json."""
import re
import pytest
from app.services.fingerprint import _load, match_service
_NODE_TYPES = {
"isp", "router", "switch", "server", "proxmox", "vm", "lxc",
"nas", "iot", "ap", "camera", "generic",
}
@pytest.fixture
def signatures():
return _load()
def test_all_entries_well_formed(signatures):
for sig in signatures:
# port is an int or explicitly null (port-agnostic)
assert sig.get("port") is None or isinstance(sig["port"], int)
assert isinstance(sig["service_name"], str) and sig["service_name"]
assert sig["suggested_node_type"] in _NODE_TYPES
if sig.get("banner_regex"):
re.compile(sig["banner_regex"])
if sig.get("http_regex"):
re.compile(sig["http_regex"])
def test_port_agnostic_entries_require_http_regex(signatures):
for sig in signatures:
if sig.get("port") is None:
assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}"
def test_popular_apps_have_port_agnostic_signatures(signatures):
names = {s["service_name"] for s in signatures if s.get("port") is None}
for expected in {
"Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole",
"AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr",
}:
assert expected in names, f"missing port-agnostic signature for {expected}"
@pytest.mark.parametrize(("title", "expected"), [
("Jellyfin", "Jellyfin"),
("Home Assistant", "Home Assistant"),
("Portainer", "Portainer"),
("Vaultwarden Web Vault", "Vaultwarden"),
("Pi-hole - Dashboard", "Pi-hole"),
("Audiobookshelf", "Audiobookshelf"),
])
def test_custom_port_identified_via_http_title(title, expected):
# A service on a non-standard port, recognised purely by its HTML title.
sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}})
assert sig is not None
assert sig["service_name"] == expected
def test_custom_port_without_probe_is_unknown():
# Same custom port, deep scan off → no signal → no port-agnostic match.
assert match_service(58000, "tcp", banner=None, http_signals=None) is None
-100
View File
@@ -1,100 +0,0 @@
"""API tests for /api/v1/stats/* (gethomepage widget)."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
@pytest.fixture(autouse=True)
def _reset_homepage_key():
original = settings.homepage_api_key
settings.homepage_api_key = ""
yield
settings.homepage_api_key = original
@pytest.mark.asyncio
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
res = await client.get("/api/v1/stats/summary")
assert res.status_code == 403
assert "disabled" in res.json()["detail"].lower()
@pytest.mark.asyncio
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
settings.homepage_api_key = "topsecret"
res = await client.get("/api/v1/stats/summary")
assert res.status_code == 403
@pytest.mark.asyncio
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
settings.homepage_api_key = "topsecret"
res = await client.get(
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_summary_empty_db(client: AsyncClient) -> None:
settings.homepage_api_key = "topsecret"
res = await client.get(
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
)
assert res.status_code == 200
body = res.json()
assert body == {
"nodes": 0,
"online": 0,
"offline": 0,
"unknown": 0,
"pending_devices": 0,
"zigbee_devices": 0,
"last_scan_at": None,
}
@pytest.mark.asyncio
async def test_summary_aggregates_counts(
client: AsyncClient, db_session: AsyncSession
) -> None:
settings.homepage_api_key = "topsecret"
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
db_session.add_all([
Node(type="server", label="A", status="online"),
Node(type="server", label="B", status="online"),
Node(type="server", label="C", status="offline"),
Node(type="server", label="D", status="unknown"),
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
PendingDevice(ip="10.0.0.1", status="pending"),
PendingDevice(ip="10.0.0.2", status="pending"),
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
ScanRun(status="success", finished_at=finished),
ScanRun(status="success",
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
])
await db_session.commit()
res = await client.get(
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
)
assert res.status_code == 200
body = res.json()
assert body["nodes"] == 6
assert body["online"] == 4
assert body["offline"] == 1
assert body["unknown"] == 1
assert body["pending_devices"] == 2
assert body["zigbee_devices"] == 2
# SQLite returns naive datetimes; compare prefix only.
assert body["last_scan_at"] is not None
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
+1 -67
View File
@@ -5,13 +5,7 @@ import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.api.routes.status import (
_connections,
_drop,
broadcast_scan_update,
broadcast_service_status,
broadcast_status,
)
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
from app.main import app
# ---------------------------------------------------------------------------
@@ -161,63 +155,3 @@ async def test_broadcast_no_connections():
assert len(_connections) == 0
await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_scan_update(run_id="r", devices_found=0)
# ---------------------------------------------------------------------------
# broadcast_service_status
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_service_status_payload():
received: list[str] = []
class FakeWS:
async def send_text(self, text: str) -> None:
received.append(text)
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_service_status(
node_id="node-7",
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
checked_at="2024-01-01T00:00:00",
)
finally:
_drop(fake)
msg = json.loads(received[0])
assert msg["type"] == "service_status"
assert msg["node_id"] == "node-7"
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
# ---------------------------------------------------------------------------
# _drop — idempotent connection removal (regression for double-remove crash)
# ---------------------------------------------------------------------------
def test_drop_is_idempotent():
"""Dropping a connection twice must not raise (was a ValueError crash)."""
class FakeWS:
pass
fake = FakeWS()
_connections.append(fake)
_drop(fake)
_drop(fake) # second drop must be a no-op
assert fake not in _connections
@pytest.mark.asyncio
async def test_broadcast_dead_connection_dropped_once_safely():
"""A send failure removes the dead socket without a double-remove crash."""
class DeadWS:
async def send_text(self, _: str) -> None:
raise RuntimeError("disconnected")
dead = DeadWS()
_connections.append(dead)
await broadcast_status(node_id="n", status="online", checked_at="t")
# A second broadcast must not raise even though dead is already gone.
await broadcast_status(node_id="n", status="online", checked_at="t")
assert dead not in _connections
+1 -332
View File
@@ -3,26 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.status_checker import (
_http_get,
_ping,
_tcp_connect,
check_node,
check_service,
check_services,
)
def _mock_httpx_client(status_code):
"""Build a stand-in for httpx.AsyncClient whose GET returns status_code."""
resp = MagicMock()
resp.status_code = status_code
client = MagicMock()
client.get = AsyncMock(return_value=resp)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=client)
ctx.__aexit__ = AsyncMock(return_value=False)
return MagicMock(return_value=ctx)
from app.services.status_checker import _tcp_connect, check_node
# --- check_node dispatcher ---
@@ -168,172 +149,6 @@ async def test_check_node_exception_returns_offline():
assert result["response_time_ms"] is None
# --- _ping platform args ---
@pytest.mark.asyncio
async def test_ping_uses_unix_args_on_non_windows():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-c" in captured["args"]
assert "-W" in captured["args"]
assert "-n" not in captured["args"]
# 2 probes so a single dropped packet doesn't flap the node offline
c_idx = captured["args"].index("-c")
assert captured["args"][c_idx + 1] == "2"
# Linux: -W is in seconds; 2s is the intended timeout
w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "2"
# IPv4 target → no -6 flag
assert "-6" not in captured["args"]
@pytest.mark.asyncio
async def test_ping_uses_macos_millisecond_timeout():
"""macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "darwin"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-c" in captured["args"]
assert "-W" in captured["args"]
w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "2000"
@pytest.mark.asyncio
async def test_ping_uses_windows_args_on_win32():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-n" in captured["args"]
assert "-w" in captured["args"]
assert "-c" not in captured["args"]
# --- _ping IPv6 support ---
@pytest.mark.asyncio
async def test_ping_ipv6_linux_uses_dash6():
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("fe80::1")
assert "-6" in captured["args"]
assert captured["args"][-1] == "fe80::1"
@pytest.mark.asyncio
async def test_ping_ipv6_macos_uses_ping6():
"""macOS ships a separate ping6 binary for IPv6 targets."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "darwin"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert captured["args"][0] == "ping6"
@pytest.mark.asyncio
async def test_ping_ipv6_windows_uses_dash6():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert "-6" in captured["args"]
def test_is_ipv6_detection():
from app.services.status_checker import _is_ipv6
assert _is_ipv6("fe80::1") is True
assert _is_ipv6("2001:db8::1") is True
assert _is_ipv6("[2001:db8::1]") is True
assert _is_ipv6("192.168.1.1") is False
assert _is_ipv6("example.local") is False
# --- check_node target validation ---
@pytest.mark.asyncio
async def test_check_node_rejects_flag_like_target():
"""A target starting with '-' must never reach subprocess invocation."""
from app.services.status_checker import check_node
with patch("asyncio.create_subprocess_exec") as mock_exec:
result = await check_node("ping", "-O", None)
mock_exec.assert_not_called()
assert result["status"] == "unknown"
@pytest.mark.asyncio
async def test_check_node_rejects_flag_like_ip():
from app.services.status_checker import check_node
with patch("asyncio.create_subprocess_exec") as mock_exec:
result = await check_node("ping", None, "-O")
mock_exec.assert_not_called()
assert result["status"] == "unknown"
# --- _tcp_connect ---
@pytest.mark.asyncio
@@ -361,149 +176,3 @@ async def test_tcp_connect_os_error():
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
result = await _tcp_connect("192.168.1.1", 9999)
assert result is False
# --- check_service ---
@pytest.mark.asyncio
async def test_check_service_no_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
@pytest.mark.asyncio
async def test_check_service_flag_host_is_unknown():
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
@pytest.mark.asyncio
async def test_check_service_udp_is_unknown():
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_portless_non_web_is_unknown():
svc = {"protocol": "tcp", "service_name": "thing"}
assert await check_service(svc, "10.0.0.1") == "unknown"
@pytest.mark.asyncio
async def test_check_service_web_uses_http_get():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
result = await check_service(svc, "10.0.0.1")
assert result == "online"
assert captured["url"] == "http://10.0.0.1:8080"
@pytest.mark.asyncio
async def test_check_service_https_port_uses_https_scheme():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "10.0.0.1")
assert captured["url"].startswith("https://")
@pytest.mark.asyncio
async def test_check_service_web_offline_when_http_fails():
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
assert await check_service(svc, "10.0.0.1") == "offline"
@pytest.mark.asyncio
async def test_check_service_non_http_port_is_unknown():
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
mock_http.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ssh_port_22_is_unknown():
"""SSH (port 22) is never checked — keep it grey, not red/green."""
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
result = await check_service(svc, "10.0.0.1")
assert result == "unknown"
mock_tcp.assert_not_called()
@pytest.mark.asyncio
async def test_check_service_ipv6_brackets_url_host():
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, "2001:db8::1")
assert captured["url"] == "http://[2001:db8::1]:80"
@pytest.mark.asyncio
async def test_check_services_returns_status_per_service():
services = [
{"port": 80, "protocol": "tcp", "service_name": "http"},
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
]
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 80, "protocol": "tcp", "status": "online"},
{"port": 5432, "protocol": "tcp", "status": "unknown"},
]
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []
# --- _http_get status-code interpretation (real primitive, mocked transport) ---
@pytest.mark.asyncio
async def test_http_get_true_on_2xx():
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(200)):
assert await _http_get("http://host") is True
@pytest.mark.asyncio
async def test_http_get_true_on_4xx():
# A 4xx means the server is up and answering — still "online".
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(404)):
assert await _http_get("http://host") is True
@pytest.mark.asyncio
async def test_http_get_false_on_5xx():
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(503)):
assert await _http_get("http://host") is False
@pytest.mark.asyncio
async def test_check_service_http_exception_returns_offline():
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch(
"app.services.status_checker._http_get",
new_callable=AsyncMock,
side_effect=RuntimeError("connection refused"),
):
assert await check_service(svc, "10.0.0.1") == "offline"
-845
View File
@@ -1,845 +0,0 @@
"""API endpoint tests for /api/v1/zigbee/*."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# /api/v1/zigbee/test-connection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
mock_conn.return_value = True
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is True
assert "success" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
mock_conn.side_effect = ConnectionError("Connection refused")
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is False
assert "refused" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zigbee/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 99999},
headers=headers,
)
assert res.status_code == 422 # pydantic validation error
# ---------------------------------------------------------------------------
# /api/v1/zigbee/import
# ---------------------------------------------------------------------------
_SAMPLE_NODES = [
{
"id": "0x00000000",
"label": "Coordinator",
"type": "zigbee_coordinator",
"ieee_address": "0x00000000",
"friendly_name": "Coordinator",
"device_type": "Coordinator",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "0x00000001",
"label": "router_1",
"type": "zigbee_router",
"ieee_address": "0x00000001",
"friendly_name": "router_1",
"device_type": "Router",
"model": "CC2530",
"vendor": "Texas Instruments",
"lqi": 230,
"parent_id": "0x00000000",
},
]
_SAMPLE_EDGES = [
{"source": "0x00000000", "target": "0x00000001"},
]
@pytest.mark.asyncio
async def test_import_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "localhost",
"mqtt_port": 1883,
"base_topic": "zigbee2mqtt",
},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 2
assert len(data["nodes"]) == 2
assert len(data["edges"]) == 1
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
assert coordinator["ieee_address"] == "0x00000000"
@pytest.mark.asyncio
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "localhost",
"mqtt_port": 1883,
"mqtt_username": "admin",
"mqtt_password": "secret",
"base_topic": "z2m",
},
headers=headers,
)
assert res.status_code == 200
mock_fetch.assert_called_once_with(
mqtt_host="localhost",
mqtt_port=1883,
base_topic="z2m",
username="admin",
password="secret",
tls=False,
tls_insecure=False,
)
@pytest.mark.asyncio
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.side_effect = ConnectionError("broker unreachable")
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 502
assert "broker unreachable" in res.json()["detail"]
@pytest.mark.asyncio
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.side_effect = TimeoutError("timed out")
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 504
@pytest.mark.asyncio
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.side_effect = ValueError("malformed response")
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_import_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
"""An empty Zigbee network (coordinator only) is a valid response."""
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 0
assert data["nodes"] == []
assert data["edges"] == []
@pytest.mark.asyncio
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zigbee/import",
json={"mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 8883,
"mqtt_tls": True,
},
headers=headers,
)
assert res.status_code == 200
kwargs = mock_fetch.call_args.kwargs
assert kwargs["tls"] is True
assert kwargs["tls_insecure"] is False
@pytest.mark.asyncio
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zigbee/import",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 1883,
"mqtt_tls": False,
"mqtt_tls_insecure": True,
},
headers=headers,
)
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zigbee/import-pending
# ---------------------------------------------------------------------------
_PENDING_NODES = [
{
"id": "0xCOORD",
"label": "Coordinator",
"type": "zigbee_coordinator",
"ieee_address": "0xCOORD",
"friendly_name": "Coordinator",
"device_type": "Coordinator",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "0xR1",
"label": "router_1",
"type": "zigbee_router",
"ieee_address": "0xR1",
"friendly_name": "router_1",
"device_type": "Router",
"model": "CC2530",
"vendor": "TI",
"lqi": 220,
"parent_id": "0xCOORD",
},
{
"id": "0xE1",
"label": "bulb_kitchen",
"type": "zigbee_enddevice",
"ieee_address": "0xE1",
"friendly_name": "bulb_kitchen",
"device_type": "EndDevice",
"model": "TRADFRI",
"vendor": "IKEA",
"lqi": 180,
"parent_id": "0xR1",
},
]
_PENDING_EDGES = [
{"source": "0xCOORD", "target": "0xR1"},
{"source": "0xR1", "target": "0xE1"},
]
@pytest.mark.asyncio
async def test_import_pending_endpoint_creates_zigbee_scan_run(
client: AsyncClient, headers: dict
) -> None:
"""Endpoint returns a ScanRun (kind=zigbee, status=running) immediately;
the actual networkmap fetch + pending persist runs in the background."""
from unittest.mock import AsyncMock
with patch(
"app.api.routes.zigbee._background_zigbee_import",
new_callable=AsyncMock,
):
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
run = res.json()
assert run["kind"] == "zigbee"
assert run["status"] == "running"
assert run["ranges"] == ["localhost:1883"]
@pytest.mark.asyncio
async def test_persist_pending_import_coordinator_goes_to_pending(
db_session,
) -> None:
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
assert result.pending_created == 3 # coordinator included now
assert result.pending_updated == 0
assert result.coordinator is None # not auto-placed
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
# No canvas Node auto-created for the coordinator.
nodes = (await db_session.execute(select(Node))).scalars().all()
assert nodes == []
# Coordinator sits in the pending inventory.
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.status == "pending"
assert coord.suggested_type == "zigbee_coordinator"
assert coord.device_subtype == "Coordinator"
@pytest.mark.asyncio
async def test_persist_pending_import_idempotent_updates_existing(
db_session,
) -> None:
from app.api.routes.zigbee import _persist_pending_import
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 99
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
assert result.pending_created == 0
assert result.pending_updated == 3 # coordinator upserts too
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
@pytest.mark.asyncio
async def test_persist_pending_import_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
@pytest.mark.asyncio
async def test_persist_pending_import_sets_coordinator_pending_fields(db_session) -> None:
"""Coordinator lands in pending carrying its vendor/model metadata."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
nodes_with_meta[0]["vendor"] = "TI"
nodes_with_meta[0]["model"] = "CC2652"
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.vendor == "TI"
assert coord.model == "CC2652"
assert coord.suggested_type == "zigbee_coordinator"
@pytest.mark.asyncio
async def test_persist_pending_import_backfills_inventory_for_approved_node(
db_session,
) -> None:
"""A device on canvas but missing its inventory row gets one backfilled
(status="approved"), so it shows in the inventory list. Node props still
refresh with the latest Vendor/Model/LQI.
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
# Simulate: router was approved earlier → exists as a canvas Node, but with
# no matching pending_devices row (e.g. a legacy auto-placed device).
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
ieee_address="0xR1",
services=[],
properties=[],
)
db_session.add(approved)
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 250 # new LQI from re-import
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
# An inventory row is backfilled as "approved" (it is on a canvas).
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "approved"
assert inv.suggested_type == "zigbee_router"
assert inv.device_subtype == "Router"
# Node properties got refreshed.
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
).scalar_one()
keys = {p["key"]: p["value"] for p in refreshed.properties}
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
# Brand-new props on an existing Node start hidden.
assert all(p["visible"] is False for p in refreshed.properties)
@pytest.mark.asyncio
async def test_persist_pending_import_preserves_hidden_inventory_for_approved_node(
db_session,
) -> None:
"""If the on-canvas device already has a hidden inventory row, re-import
refreshes its metadata but must NOT flip it back to approved/visible."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
db_session.add(Node(
label="router_1", type="zigbee_router", status="online",
check_method="none", ieee_address="0xR1", services=[], properties=[],
))
db_session.add(PendingDevice(
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
device_subtype="Router", status="hidden", discovery_source="zigbee",
))
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "hidden" # stays hidden
@pytest.mark.asyncio
async def test_persist_pending_import_revives_orphaned_approved_device(
db_session,
) -> None:
"""Regression for #167: approve → delete node → re-import must re-list device.
When a device was approved (PendingDevice.status="approved") and its canvas
Node was later deleted, the orphaned "approved" row must be reset to
"pending" on re-import so it shows up in the Pending list again instead of
being silently swallowed (re-import reports "found" but Pending stays empty).
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
# Simulate prior approve: a PendingDevice marked approved, but NO matching
# Node exists (the user deleted the canvas node afterwards).
orphan = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
hostname="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
model="CC2530",
vendor="TI",
lqi=220,
status="approved",
discovery_source="zigbee",
)
db_session.add(orphan)
await db_session.commit()
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
# No new row created for 0xR1 — the existing one was updated/revived.
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert revived.status == "pending"
# Coordinator + end device 0xE1 are brand new → created; router was revived.
assert result.pending_created == 2
assert result.pending_updated == 1
# It is now visible to the Pending list (status filter == "pending").
listed = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.status == "pending")
)
).scalars().all()
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
@pytest.mark.asyncio
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
db_session,
) -> None:
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
hidden = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="hidden",
discovery_source="zigbee",
)
db_session.add(hidden)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert still_hidden.status == "hidden"
@pytest.mark.asyncio
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
"""If user has already made props visible, re-import must not flip them back."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
ieee_address="0xR1",
services=[],
properties=[
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
],
)
db_session.add(approved)
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 99
bumped[1]["model"] = "CC2530"
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
).scalar_one()
by_key = {p["key"]: p for p in refreshed.properties}
# Existing keys keep their visibility (True).
assert by_key["IEEE"]["visible"] is True
assert by_key["Vendor"]["visible"] is True
# New key arrives hidden.
assert by_key["Model"]["visible"] is False
assert by_key["LQI"]["visible"] is False
assert by_key["LQI"]["value"] == "99"
# Non-zigbee user-added prop is preserved untouched.
assert by_key["Custom"]["value"] == "kept"
assert by_key["Custom"]["visible"] is True
@pytest.mark.asyncio
async def test_persist_pending_import_refreshes_approved_coordinator_node(
db_session,
) -> None:
"""An already-approved coordinator Node gets its props refreshed on
re-import, and a backfilled inventory row (approved) so it shows in the
inventory list this is the legacy auto-placed coordinator scenario."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
# Legacy: coordinator auto-placed as a canvas Node, no pending_devices row.
db_session.add(Node(
label="Coordinator", type="zigbee_coordinator", status="online",
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
))
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[0]["vendor"] = "TI"
bumped[0]["model"] = "CC2652"
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
coord = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
).scalar_one()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys["Vendor"] == "TI"
assert keys["Model"] == "CC2652"
by_key = {p["key"]: p for p in coord.properties}
assert by_key["Vendor"]["visible"] is False
# Inventory row backfilled as approved → now visible in the inventory list.
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert inv.status == "approved"
assert inv.suggested_type == "zigbee_coordinator"
@pytest.mark.asyncio
async def test_persist_pending_import_device_on_multiple_canvases(
db_session,
) -> None:
"""Regression: a device approved onto TWO designs (one Node each) must not
crash re-import with MultipleResultsFound props refresh on both nodes."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Design, Node
d1 = Design(name="d1")
d2 = Design(name="d2")
db_session.add_all([d1, d2])
await db_session.flush()
for d in (d1, d2):
db_session.add(Node(
label="router_1", type="zigbee_router", status="online",
check_method="none", ieee_address="0xR1", services=[],
properties=[], design_id=d.id,
))
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 240
# Must not raise.
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
).scalars().all()
assert len(nodes) == 2 # both canvas placements preserved
for n in nodes:
lqi = {p["key"]: p["value"] for p in n.properties}.get("LQI")
assert lqi == "240" # refreshed on every canvas
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
mock_conn.return_value = True
res = await client.post(
"/api/v1/zigbee/test-connection",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 8883,
"mqtt_tls": True,
"mqtt_tls_insecure": True,
},
headers=headers,
)
assert res.status_code == 200
kwargs = mock_conn.call_args.kwargs
assert kwargs["tls"] is True
assert kwargs["tls_insecure"] is True
# ---------------------------------------------------------------------------
# /api/v1/zigbee/config + /sync-now (auto-sync)
# ---------------------------------------------------------------------------
from app.core.config import settings # noqa: E402
@pytest.fixture
def _restore_zigbee_env():
"""Snapshot + restore the env-only Zigbee connection/activation settings."""
keys = (
"zigbee_mqtt_host", "zigbee_mqtt_port", "zigbee_mqtt_username",
"zigbee_mqtt_password", "zigbee_base_topic", "zigbee_sync_enabled",
"zigbee_sync_interval",
)
saved = {k: getattr(settings, k) for k in keys}
yield
for k, v in saved.items():
setattr(settings, k, v)
@pytest.mark.asyncio
async def test_config_omits_credentials(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
settings.zigbee_mqtt_username = "user"
settings.zigbee_mqtt_password = "supersecret"
res = await client.get("/api/v1/zigbee/config", headers=headers)
assert res.status_code == 200
assert "supersecret" not in res.text
assert "user" not in res.text
assert res.json()["host_configured"] is True
@pytest.mark.asyncio
async def test_config_requires_auth(client: AsyncClient) -> None:
res = await client.get("/api/v1/zigbee/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_enable_sync_without_host_rejected(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = ""
res = await client.post(
"/api/v1/zigbee/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.zigbee_mqtt_host, enabled=self.zigbee_sync_enabled,
interval=self.zigbee_sync_interval,
)), patch("app.api.routes.zigbee.set_zigbee_sync_enabled"), \
patch("app.api.routes.zigbee.reschedule_zigbee_sync"):
res = await client.post(
"/api/v1/zigbee/config",
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
assert settings.zigbee_mqtt_host == "broker" # body host ignored
assert saved == {"host": "broker", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
with patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock):
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "zigbee"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_host(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = ""
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/zigbee/sync-now")
assert res.status_code == 401
-573
View File
@@ -1,573 +0,0 @@
"""Unit tests for zigbee_service: parser and hierarchy builder."""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import patch
import aiomqtt # noqa: F401
import pytest
from app.services.zigbee_service import (
_find_parent_router,
_z2m_type_to_homelable,
fetch_networkmap,
parse_networkmap,
)
from app.services.zigbee_service import (
test_mqtt_connection as _test_mqtt_connection,
)
# ---------------------------------------------------------------------------
# Helper builders — real Z2M `bridge/response/networkmap` shape
# (data.value.nodes + data.value.links)
# ---------------------------------------------------------------------------
def _make_node(
ieee: str,
device_type: str = "EndDevice",
friendly_name: str | None = None,
model: str | None = None,
vendor: str | None = None,
) -> dict[str, Any]:
entry: dict[str, Any] = {
"ieeeAddr": ieee,
"type": device_type,
"friendlyName": friendly_name or ieee,
}
if model or vendor:
entry["definition"] = {"model": model, "vendor": vendor}
return entry
def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]:
return {
"source": {"ieeeAddr": source_ieee},
"target": {"ieeeAddr": target_ieee},
"lqi": lqi,
}
def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]:
return {
"data": {
"type": "raw",
"routes": False,
"value": {"nodes": nodes, "links": links or []},
},
"status": "ok",
}
# ---------------------------------------------------------------------------
# _z2m_type_to_homelable
# ---------------------------------------------------------------------------
class TestZ2mTypeToHomelable:
def test_coordinator(self) -> None:
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
def test_router(self) -> None:
assert _z2m_type_to_homelable("Router") == "zigbee_router"
def test_enddevice(self) -> None:
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
def test_unknown_defaults_to_enddevice(self) -> None:
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
# ---------------------------------------------------------------------------
# parse_networkmap
# ---------------------------------------------------------------------------
class TestParseNetworkmap:
def test_empty_payload(self) -> None:
nodes, edges = parse_networkmap({})
assert nodes == []
assert edges == []
def test_empty_value(self) -> None:
nodes, edges = parse_networkmap(_wrap([], []))
assert nodes == []
assert edges == []
def test_coordinator_only(self) -> None:
payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")])
nodes, edges = parse_networkmap(payload)
assert len(nodes) == 1
assert nodes[0]["type"] == "zigbee_coordinator"
assert nodes[0]["ieee_address"] == "0x0000000000000000"
assert edges == []
def test_coordinator_router_enddevice(self) -> None:
coord_ieee = "0x0000000000000000"
router_ieee = "0x0000000000000001"
end_ieee = "0x0000000000000002"
payload = _wrap(
nodes=[
_make_node(coord_ieee, "Coordinator", "Coordinator"),
_make_node(router_ieee, "Router", "my_router"),
_make_node(end_ieee, "EndDevice"),
],
links=[
_make_link(coord_ieee, router_ieee),
_make_link(router_ieee, end_ieee),
],
)
nodes, edges = parse_networkmap(payload)
node_by_id = {n["id"]: n for n in nodes}
assert coord_ieee in node_by_id
assert router_ieee in node_by_id
assert end_ieee in node_by_id
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
assert node_by_id[router_ieee]["type"] == "zigbee_router"
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
# Parent hierarchy
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
assert node_by_id[end_ieee]["parent_id"] == router_ieee
assert len(edges) == 2
def test_no_duplicate_nodes(self) -> None:
ieee = "0x0000000000000001"
payload = _wrap(
nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")],
)
nodes, _ = parse_networkmap(payload)
assert len(nodes) == 1
def test_edges_built_correctly(self) -> None:
coord = "0x0000"
router = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
links=[_make_link(coord, router)],
)
_, edges = parse_networkmap(payload)
assert len(edges) == 1
assert edges[0]["source"] == coord
assert edges[0]["target"] == router
def test_friendly_name_used_as_label(self) -> None:
payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")])
nodes, _ = parse_networkmap(payload)
assert nodes[0]["label"] == "Living Room Sensor"
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
coord = "0x0000"
end = "0x0003"
payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")])
nodes, _ = parse_networkmap(payload)
end_node = next(n for n in nodes if n["id"] == end)
assert end_node["parent_id"] == coord
def test_missing_ieee_skipped(self) -> None:
payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr
nodes, edges = parse_networkmap(payload)
assert nodes == []
assert edges == []
def test_lqi_propagated_from_link_to_target_node(self) -> None:
coord = "0x0000"
end = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
links=[_make_link(coord, end, lqi=180)],
)
nodes, _ = parse_networkmap(payload)
end_node = next(n for n in nodes if n["id"] == end)
assert end_node["lqi"] == 180
def test_definition_model_and_vendor_extracted(self) -> None:
payload = _wrap([
_make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"),
])
nodes, _ = parse_networkmap(payload)
assert nodes[0]["model"] == "WSDCGQ11LM"
assert nodes[0]["vendor"] == "Aqara"
def test_legacy_shape_without_value_wrapper(self) -> None:
"""Some Z2M variants put nodes/links directly under data."""
payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}}
nodes, _ = parse_networkmap(payload)
assert len(nodes) == 1
assert nodes[0]["type"] == "zigbee_coordinator"
def test_routes_bool_is_ignored(self) -> None:
"""`routes: false` echo from the request must not crash the parser."""
payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}}
nodes, edges = parse_networkmap(payload)
assert nodes == []
assert edges == []
def test_malformed_nodes_not_list_raises(self) -> None:
with pytest.raises(ValueError, match="not a list"):
parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}})
def test_link_to_unknown_node_dropped(self) -> None:
payload = _wrap(
nodes=[_make_node("0x01", "Coordinator")],
links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes
)
_, edges = parse_networkmap(payload)
assert edges == []
def test_bidirectional_links_yield_single_edge(self) -> None:
"""Z2M links are bidirectional — every pair appears twice. The output
must collapse to a single parentchild edge (no back-link, no dup)."""
coord = "0x0000"
router = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
links=[
_make_link(coord, router),
_make_link(router, coord), # reverse direction
],
)
_, edges = parse_networkmap(payload)
assert edges == [{"source": coord, "target": router}]
def test_router_mesh_siblings_dropped(self) -> None:
"""Router↔router mesh paths in `links` must NOT produce sibling edges
in the final tree. Each router gets exactly one edge from coordinator."""
coord = "0x0000"
r1 = "0x0001"
r2 = "0x0002"
payload = _wrap(
nodes=[
_make_node(coord, "Coordinator"),
_make_node(r1, "Router"),
_make_node(r2, "Router"),
],
links=[
_make_link(coord, r1),
_make_link(coord, r2),
_make_link(r1, r2), # mesh sibling — must be dropped
_make_link(r2, r1),
],
)
_, edges = parse_networkmap(payload)
pairs = {(e["source"], e["target"]) for e in edges}
assert pairs == {(coord, r1), (coord, r2)}
def test_coordinator_has_no_incoming_edge(self) -> None:
coord = "0x0000"
end = "0x0001"
payload = _wrap(
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
links=[_make_link(end, coord)], # back-edge from end to coord
)
_, edges = parse_networkmap(payload)
# No edge should target the coordinator
assert all(e["target"] != coord for e in edges)
assert edges == [{"source": coord, "target": end}]
# ---------------------------------------------------------------------------
# _find_parent_router
# ---------------------------------------------------------------------------
class TestFindParentRouter:
def test_finds_router_as_source(self) -> None:
router_ids = {"r1"}
edges = [{"source": "r1", "target": "e1"}]
assert _find_parent_router("e1", router_ids, edges) == "r1"
def test_finds_router_as_target(self) -> None:
router_ids = {"r1"}
edges = [{"source": "e1", "target": "r1"}]
assert _find_parent_router("e1", router_ids, edges) == "r1"
def test_returns_none_when_no_router(self) -> None:
router_ids: set[str] = set()
edges = [{"source": "e1", "target": "e2"}]
assert _find_parent_router("e1", router_ids, edges) is None
def test_returns_none_empty_edges(self) -> None:
assert _find_parent_router("e1", {"r1"}, []) is None
# ---------------------------------------------------------------------------
# fetch_networkmap (integration-style with mocked aiomqtt)
# ---------------------------------------------------------------------------
SAMPLE_RESPONSE_PAYLOAD = {
"data": {
"type": "raw",
"routes": False,
"value": {
"nodes": [
{
"ieeeAddr": "0x00000000",
"type": "Coordinator",
"friendlyName": "Coordinator",
},
{
"ieeeAddr": "0x00000001",
"type": "Router",
"friendlyName": "router_1",
},
],
"links": [
{
"source": {"ieeeAddr": "0x00000000"},
"target": {"ieeeAddr": "0x00000001"},
"lqi": 230,
}
],
},
},
"status": "ok",
}
@pytest.mark.asyncio
async def test_fetch_networkmap_success() -> None:
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
class _FakeMessage:
topic = "zigbee2mqtt/bridge/response/networkmap"
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
_yielded = False
def __aiter__(self):
return self
async def __anext__(self):
if self._yielded:
raise StopAsyncIteration
self._yielded = True
return self
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def subscribe(self, _topic: str) -> None:
pass
async def publish(self, _topic: str, _payload: str) -> None:
pass
@property
def messages(self):
return _FakeMessage()
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
nodes, edges = await fetch_networkmap(
mqtt_host="localhost",
mqtt_port=1883,
base_topic="zigbee2mqtt",
)
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
assert any(n["type"] == "zigbee_router" for n in nodes)
@pytest.mark.asyncio
async def test_fetch_networkmap_connection_error() -> None:
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
class _FakeClient:
async def __aenter__(self):
raise Exception("Connection refused")
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await fetch_networkmap(
mqtt_host="bad-host",
mqtt_port=1883,
base_topic="zigbee2mqtt",
)
@pytest.mark.asyncio
async def test_test_mqtt_connection_success() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
result = await _test_mqtt_connection("localhost", 1883)
assert result is True
@pytest.mark.asyncio
async def test_test_mqtt_connection_failure() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("refused")
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError):
await _test_mqtt_connection("bad-host", 1883)
# ---------------------------------------------------------------------------
# TLS context
# ---------------------------------------------------------------------------
import ssl # noqa: E402
from app.services.zigbee_service import _build_tls_context # noqa: E402
def test_build_tls_context_secure_verifies_cert() -> None:
ctx = _build_tls_context(insecure=False)
assert ctx.check_hostname is True
assert ctx.verify_mode == ssl.CERT_REQUIRED
def test_build_tls_context_insecure_disables_verification() -> None:
ctx = _build_tls_context(insecure=True)
assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE
@pytest.mark.asyncio
async def test_test_mqtt_connection_passes_tls_context() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
await _test_mqtt_connection("host", 8883, tls=True)
kwargs = mock_aiomqtt.Client.call_args.kwargs
assert kwargs["tls_context"] is not None
assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED
@pytest.mark.asyncio
async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
await _test_mqtt_connection("host", 1883, tls=False)
assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None
@pytest.mark.asyncio
async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None:
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True)
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
assert ctx.verify_mode == ssl.CERT_NONE
assert ctx.check_hostname is False
# ---------------------------------------------------------------------------
# Sanitize MQTT errors
# ---------------------------------------------------------------------------
from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402
def test_sanitize_auth_error_does_not_leak_credentials() -> None:
msg = _sanitize_mqtt_error(
Exception("Not authorized: bad username or password for user=admin pwd=secret")
)
assert msg == "Authentication failed"
assert "admin" not in msg
assert "secret" not in msg
def test_sanitize_refused() -> None:
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
def test_sanitize_dns_failure_strips_host() -> None:
msg = _sanitize_mqtt_error(
Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan")
)
assert msg == "Broker hostname could not be resolved"
assert "broker.internal.lan" not in msg
def test_sanitize_tls_error() -> None:
assert _sanitize_mqtt_error(
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
) == "TLS handshake failed"
def test_sanitize_unknown_falls_back_to_generic() -> None:
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state"))
assert msg == "MQTT connection failed"
assert "hunter2" not in msg
assert "admin" not in msg
@pytest.mark.asyncio
async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None:
class _FakeClient:
async def __aenter__(self):
raise Exception("Not authorized: rejected mqtt://admin:hunter2@host")
async def __aexit__(self, *_):
pass
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
mock_aiomqtt.Client.return_value = _FakeClient()
mock_aiomqtt.MqttError = Exception
with pytest.raises(ConnectionError) as ei:
await fetch_networkmap(
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
)
msg = str(ei.value)
assert "hunter2" not in msg
assert "admin" not in msg
assert msg == "Authentication failed"
-599
View File
@@ -1,599 +0,0 @@
"""API endpoint tests for /api/v1/zwave/*."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from httpx import AsyncClient
# ---------------------------------------------------------------------------
# /api/v1/zwave/test-connection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
mock_conn.return_value = True
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is True
assert "success" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
mock_conn.side_effect = ConnectionError("Connection refused")
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["connected"] is False
assert "refused" in data["message"].lower()
@pytest.mark.asyncio
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zwave/test-connection",
json={"mqtt_host": "localhost", "mqtt_port": 99999},
headers=headers,
)
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zwave/import
# ---------------------------------------------------------------------------
_SAMPLE_NODES = [
{
"id": "zwave-0xh-1",
"label": "Controller",
"type": "zwave_coordinator",
"ieee_address": "zwave-0xh-1",
"friendly_name": "Controller",
"device_type": "Controller",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "zwave-0xh-2",
"label": "Wall Plug",
"type": "zwave_router",
"ieee_address": "zwave-0xh-2",
"friendly_name": "Wall Plug",
"device_type": "Router",
"model": "ZW100",
"vendor": "Aeotec",
"lqi": None,
"parent_id": "zwave-0xh-1",
},
]
_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
@pytest.mark.asyncio
async def test_import_success(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 2
assert len(data["edges"]) == 1
coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
assert coordinator["ieee_address"] == "zwave-0xh-1"
@pytest.mark.asyncio
async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.return_value = ([], [])
res = await client.post(
"/api/v1/zwave/import",
json={
"mqtt_host": "localhost",
"mqtt_port": 1883,
"prefix": "myzwave",
"gateway_name": "gw1",
"mqtt_username": "admin",
"mqtt_password": "secret",
},
headers=headers,
)
assert res.status_code == 200
mock_fetch.assert_called_once_with(
mqtt_host="localhost",
mqtt_port=1883,
prefix="myzwave",
gateway_name="gw1",
username="admin",
password="secret",
tls=False,
tls_insecure=False,
)
@pytest.mark.asyncio
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = ConnectionError("broker unreachable")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 502
assert "broker unreachable" in res.json()["detail"]
@pytest.mark.asyncio
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = TimeoutError("timed out")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 504
@pytest.mark.asyncio
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = ValueError("malformed response")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
mock_fetch.side_effect = RuntimeError("boom")
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 500
@pytest.mark.asyncio
async def test_import_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zwave/import",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/zwave/import",
json={
"mqtt_host": "broker.example.com",
"mqtt_port": 1883,
"mqtt_tls": False,
"mqtt_tls_insecure": True,
},
headers=headers,
)
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zwave/import-pending
# ---------------------------------------------------------------------------
_PENDING_NODES = [
{
"id": "zwave-0xh-1",
"label": "Controller",
"type": "zwave_coordinator",
"ieee_address": "zwave-0xh-1",
"friendly_name": "Controller",
"device_type": "Controller",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "zwave-0xh-2",
"label": "Wall Plug",
"type": "zwave_router",
"ieee_address": "zwave-0xh-2",
"friendly_name": "Wall Plug",
"device_type": "Router",
"model": "ZW100",
"vendor": "Aeotec",
"lqi": None,
"parent_id": "zwave-0xh-1",
},
{
"id": "zwave-0xh-3",
"label": "Door Sensor",
"type": "zwave_enddevice",
"ieee_address": "zwave-0xh-3",
"friendly_name": "Door Sensor",
"device_type": "EndDevice",
"model": "ZW120",
"vendor": "Aeotec",
"lqi": None,
"parent_id": "zwave-0xh-2",
},
]
_PENDING_EDGES = [
{"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
{"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
]
@pytest.mark.asyncio
async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
from unittest.mock import AsyncMock
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
res = await client.post(
"/api/v1/zwave/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
run = res.json()
assert run["kind"] == "zwave"
assert run["status"] == "running"
assert run["ranges"] == ["localhost:1883"]
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zwave/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_persist_coordinator_goes_to_pending(db_session) -> None:
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
assert result.pending_created == 3 # coordinator included now
assert result.pending_updated == 0
assert result.coordinator is None # not auto-placed
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
nodes = (await db_session.execute(select(Node))).scalars().all()
assert nodes == []
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.status == "pending"
assert coord.suggested_type == "zwave_coordinator"
@pytest.mark.asyncio
async def test_persist_idempotent_updates_existing(db_session) -> None:
from app.api.routes.zwave import _persist_pending_import
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["model"] = "ZW111"
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
assert result.pending_created == 0
assert result.pending_updated == 3 # coordinator upserts too
assert result.coordinator_already_existed is False
@pytest.mark.asyncio
async def test_persist_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
@pytest.mark.asyncio
async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
"""Coordinator lands in pending carrying its vendor/model metadata."""
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
nodes = [dict(n) for n in _PENDING_NODES]
nodes[0]["vendor"] = "Aeotec"
nodes[0]["model"] = "ZW090"
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.vendor == "Aeotec"
assert coord.model == "ZW090"
assert coord.suggested_type == "zwave_coordinator"
@pytest.mark.asyncio
async def test_persist_backfills_inventory_for_approved_node(db_session) -> None:
"""On-canvas device missing its inventory row gets one backfilled
(status="approved"); Node props still refresh."""
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
approved = Node(
label="Wall Plug",
type="zwave_router",
status="online",
check_method="none",
ieee_address="zwave-0xh-2",
services=[],
properties=[],
)
db_session.add(approved)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert inv.status == "approved"
assert inv.suggested_type == "zwave_router"
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
).scalar_one()
keys = {p["key"]: p["value"] for p in refreshed.properties}
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
@pytest.mark.asyncio
async def test_persist_revives_orphaned_approved_device(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
orphan = PendingDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
device_subtype="Router",
status="approved",
discovery_source="zwave",
)
db_session.add(orphan)
await db_session.commit()
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert revived.status == "pending"
# Coordinator + end device are brand new → created; router was revived.
assert result.pending_created == 2
assert result.pending_updated == 1
@pytest.mark.asyncio
async def test_persist_keeps_hidden_hidden(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
hidden = PendingDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
device_subtype="Router",
status="hidden",
discovery_source="zwave",
)
db_session.add(hidden)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert still_hidden.status == "hidden"
@pytest.mark.asyncio
async def test_persist_device_on_multiple_canvases(db_session) -> None:
"""Regression: a device on TWO designs (one Node each) must not crash
re-import with MultipleResultsFound props refresh on both nodes."""
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Design, Node
d1 = Design(name="d1")
d2 = Design(name="d2")
db_session.add_all([d1, d2])
await db_session.flush()
for d in (d1, d2):
db_session.add(Node(
label="Wall Plug", type="zwave_router", status="online",
check_method="none", ieee_address="zwave-0xh-2", services=[],
properties=[], design_id=d.id,
))
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["model"] = "ZW200"
# Must not raise.
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
).scalars().all()
assert len(nodes) == 2 # both canvas placements preserved
for n in nodes:
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
assert model == "ZW200" # refreshed on every canvas
# ---------------------------------------------------------------------------
# /api/v1/zwave/config + /sync-now (auto-sync)
# ---------------------------------------------------------------------------
from unittest.mock import AsyncMock # noqa: E402
from app.core.config import settings # noqa: E402
@pytest.fixture
def _restore_zwave_env():
"""Snapshot + restore the env-only Z-Wave connection/activation settings."""
keys = (
"zwave_mqtt_host", "zwave_mqtt_port", "zwave_mqtt_username",
"zwave_mqtt_password", "zwave_prefix", "zwave_gateway_name",
"zwave_sync_enabled", "zwave_sync_interval",
)
saved = {k: getattr(settings, k) for k in keys}
yield
for k, v in saved.items():
setattr(settings, k, v)
@pytest.mark.asyncio
async def test_config_omits_credentials(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
settings.zwave_mqtt_username = "user"
settings.zwave_mqtt_password = "supersecret"
res = await client.get("/api/v1/zwave/config", headers=headers)
assert res.status_code == 200
assert "supersecret" not in res.text
assert "user" not in res.text
assert res.json()["host_configured"] is True
@pytest.mark.asyncio
async def test_config_requires_auth(client: AsyncClient) -> None:
res = await client.get("/api/v1/zwave/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_enable_sync_without_host_rejected(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = ""
res = await client.post(
"/api/v1/zwave/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.zwave_mqtt_host, enabled=self.zwave_sync_enabled,
interval=self.zwave_sync_interval,
)), patch("app.api.routes.zwave.set_zwave_sync_enabled"), \
patch("app.api.routes.zwave.reschedule_zwave_sync"):
res = await client.post(
"/api/v1/zwave/config",
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
assert settings.zwave_mqtt_host == "broker" # body host ignored
assert saved == {"host": "broker", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "zwave"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_host(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = ""
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/zwave/sync-now")
assert res.status_code == 401

Some files were not shown because too many files have changed in this diff Show More