Merge pull request #247 from Pouzor/fix/mesh-import-duplicate-nodes
fix: mesh (zigbee/zwave) import — duplicate-node crash + coordinator to pending
This commit is contained in:
@@ -15,8 +15,12 @@ from app.db.database import AsyncSessionLocal, get_db
|
|||||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
|
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||||
from app.services.zigbee_service import build_zigbee_properties
|
from app.services.zigbee_service import (
|
||||||
|
build_zigbee_properties,
|
||||||
|
merge_zigbee_properties,
|
||||||
|
)
|
||||||
from app.services.zwave_service import build_zwave_properties
|
from app.services.zwave_service import build_zwave_properties
|
||||||
|
|
||||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||||
@@ -308,6 +312,9 @@ async def bulk_approve_devices(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> 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.
|
# Target the design the user is on; fall back to the first design.
|
||||||
default_design_id = payload.design_id
|
default_design_id = payload.design_id
|
||||||
if default_design_id is None:
|
if default_design_id is None:
|
||||||
@@ -469,6 +476,35 @@ async def approve_device(
|
|||||||
raise HTTPException(status_code=409, detail="Device already processed")
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
wireless = _is_wireless(node_data.type)
|
wireless = _is_wireless(node_data.type)
|
||||||
|
|
||||||
|
# Guard against a true duplicate: same IEEE already placed on THIS design.
|
||||||
|
# (The same device on a *different* design is valid — one Node per canvas.)
|
||||||
|
if device.ieee_address is not None:
|
||||||
|
dup = (
|
||||||
|
await db.execute(
|
||||||
|
select(Node).where(
|
||||||
|
Node.ieee_address == device.ieee_address,
|
||||||
|
Node.design_id == node_design_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if dup is not None:
|
||||||
|
dup.properties = merge_zigbee_properties(
|
||||||
|
dup.properties,
|
||||||
|
_wireless_properties(
|
||||||
|
node_data.type, device.ieee_address,
|
||||||
|
device.vendor, device.model, device.lqi,
|
||||||
|
) if wireless else [],
|
||||||
|
)
|
||||||
|
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"approved": True,
|
||||||
|
"node_id": dup.id,
|
||||||
|
"edges_created": len(edges),
|
||||||
|
"edges": edges,
|
||||||
|
}
|
||||||
|
|
||||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||||
# fall back to whatever the approve payload carried.
|
# fall back to whatever the approve payload carried.
|
||||||
_mac = device.mac or node_data.mac
|
_mac = device.mac or node_data.mac
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.scan import ScanRunResponse
|
from app.schemas.scan import ScanRunResponse
|
||||||
from app.schemas.zigbee import (
|
from app.schemas.zigbee import (
|
||||||
ZigbeeCoordinatorOut,
|
ZigbeeCoordinatorOut,
|
||||||
@@ -23,6 +23,7 @@ from app.schemas.zigbee import (
|
|||||||
ZigbeeTestConnectionRequest,
|
ZigbeeTestConnectionRequest,
|
||||||
ZigbeeTestConnectionResponse,
|
ZigbeeTestConnectionResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||||
from app.services.zigbee_service import (
|
from app.services.zigbee_service import (
|
||||||
build_zigbee_properties,
|
build_zigbee_properties,
|
||||||
fetch_networkmap,
|
fetch_networkmap,
|
||||||
@@ -138,10 +139,12 @@ async def _persist_pending_import(
|
|||||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
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.
|
All zigbee-source links are wiped and re-inserted from the new map.
|
||||||
"""
|
"""
|
||||||
# Determine target design (use first design as fallback)
|
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
# the by-IEEE lookups below resolve to a single row.
|
||||||
default_design_id = first_design.id if first_design else None
|
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_out: ZigbeeCoordinatorOut | None = None
|
||||||
coordinator_existed = False
|
coordinator_existed = False
|
||||||
pending_created = 0
|
pending_created = 0
|
||||||
@@ -155,49 +158,57 @@ async def _persist_pending_import(
|
|||||||
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
||||||
)
|
)
|
||||||
|
|
||||||
if n.get("device_type") == "Coordinator":
|
# The coordinator is no longer auto-placed on the canvas — it flows to
|
||||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
# the pending inventory like every other device, so the user approves it
|
||||||
existing_node = existing.scalar_one_or_none()
|
# explicitly. Only the shared paths below run for it.
|
||||||
if existing_node:
|
|
||||||
|
# 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 = merge_zigbee_properties(
|
||||||
existing_node.properties, props
|
existing_node.properties, props
|
||||||
)
|
)
|
||||||
coordinator_out = ZigbeeCoordinatorOut(
|
inv = (
|
||||||
id=existing_node.id,
|
await db.execute(
|
||||||
label=existing_node.label,
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
ieee_address=ieee,
|
|
||||||
)
|
)
|
||||||
coordinator_existed = True
|
).scalar_one_or_none()
|
||||||
continue
|
if inv is None:
|
||||||
label = n.get("friendly_name") or ieee
|
db.add(
|
||||||
node = Node(
|
PendingDevice(
|
||||||
label=label,
|
ieee_address=ieee,
|
||||||
type=n.get("type") or "zigbee_coordinator",
|
friendly_name=n.get("friendly_name"),
|
||||||
status="online",
|
hostname=n.get("friendly_name"),
|
||||||
check_method="none",
|
suggested_type=n.get("type"),
|
||||||
ieee_address=ieee,
|
device_subtype=n.get("device_type"),
|
||||||
services=[],
|
model=n.get("model"),
|
||||||
properties=props,
|
vendor=n.get("vendor"),
|
||||||
design_id=default_design_id,
|
lqi=n.get("lqi"),
|
||||||
)
|
status="approved",
|
||||||
db.add(node)
|
discovery_source="zigbee",
|
||||||
await db.flush()
|
)
|
||||||
coordinator_out = ZigbeeCoordinatorOut(
|
)
|
||||||
id=node.id, label=label, ieee_address=ieee
|
pending_created += 1
|
||||||
)
|
else:
|
||||||
continue
|
# Refresh metadata but never change the row's status (an approved
|
||||||
|
# device stays approved; a hidden one stays hidden).
|
||||||
# If the device has already been approved as a canvas Node, refresh
|
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||||
# its properties and skip creating a pending row (keeps approved
|
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||||
# devices out of pending/hidden modals on re-import).
|
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||||
existing_node_q = await db.execute(
|
inv.model = n.get("model") or inv.model
|
||||||
select(Node).where(Node.ieee_address == ieee)
|
inv.vendor = n.get("vendor") or inv.vendor
|
||||||
)
|
if n.get("lqi") is not None:
|
||||||
existing_node = existing_node_q.scalar_one_or_none()
|
inv.lqi = n.get("lqi")
|
||||||
if existing_node:
|
pending_updated += 1
|
||||||
existing_node.properties = merge_zigbee_properties(
|
|
||||||
existing_node.properties, props
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.scan import ScanRunResponse
|
from app.schemas.scan import ScanRunResponse
|
||||||
from app.schemas.zwave import (
|
from app.schemas.zwave import (
|
||||||
ZwaveCoordinatorOut,
|
ZwaveCoordinatorOut,
|
||||||
@@ -23,6 +23,7 @@ from app.schemas.zwave import (
|
|||||||
ZwaveTestConnectionRequest,
|
ZwaveTestConnectionRequest,
|
||||||
ZwaveTestConnectionResponse,
|
ZwaveTestConnectionResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||||
from app.services.zwave_service import (
|
from app.services.zwave_service import (
|
||||||
build_zwave_properties,
|
build_zwave_properties,
|
||||||
fetch_zwave_network,
|
fetch_zwave_network,
|
||||||
@@ -134,9 +135,12 @@ async def _persist_pending_import(
|
|||||||
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
|
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.
|
identity. All zwave-source links are wiped and re-inserted from the new map.
|
||||||
"""
|
"""
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
|
||||||
default_design_id = first_design.id if first_design else None
|
# 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_out: ZwaveCoordinatorOut | None = None
|
||||||
coordinator_existed = False
|
coordinator_existed = False
|
||||||
pending_created = 0
|
pending_created = 0
|
||||||
@@ -148,47 +152,56 @@ async def _persist_pending_import(
|
|||||||
continue
|
continue
|
||||||
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
||||||
|
|
||||||
if n.get("type") == "zwave_coordinator":
|
# The coordinator is no longer auto-placed on the canvas — it flows to
|
||||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
# the pending inventory like every other device, so the user approves it
|
||||||
existing_node = existing.scalar_one_or_none()
|
# explicitly. Only the shared paths below run for it.
|
||||||
if existing_node:
|
|
||||||
|
# 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 = merge_zwave_properties(
|
||||||
existing_node.properties, props
|
existing_node.properties, props
|
||||||
)
|
)
|
||||||
coordinator_out = ZwaveCoordinatorOut(
|
inv = (
|
||||||
id=existing_node.id,
|
await db.execute(
|
||||||
label=existing_node.label,
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
ieee_address=ieee,
|
|
||||||
)
|
)
|
||||||
coordinator_existed = True
|
).scalar_one_or_none()
|
||||||
continue
|
if inv is None:
|
||||||
label = n.get("friendly_name") or ieee
|
db.add(
|
||||||
node = Node(
|
PendingDevice(
|
||||||
label=label,
|
ieee_address=ieee,
|
||||||
type=n.get("type") or "zwave_coordinator",
|
friendly_name=n.get("friendly_name"),
|
||||||
status="online",
|
hostname=n.get("friendly_name"),
|
||||||
check_method="none",
|
suggested_type=n.get("type"),
|
||||||
ieee_address=ieee,
|
device_subtype=n.get("device_type"),
|
||||||
services=[],
|
model=n.get("model"),
|
||||||
properties=props,
|
vendor=n.get("vendor"),
|
||||||
design_id=default_design_id,
|
lqi=n.get("lqi"),
|
||||||
)
|
status="approved",
|
||||||
db.add(node)
|
discovery_source="zwave",
|
||||||
await db.flush()
|
)
|
||||||
coordinator_out = ZwaveCoordinatorOut(
|
)
|
||||||
id=node.id, label=label, ieee_address=ieee
|
pending_created += 1
|
||||||
)
|
else:
|
||||||
continue
|
# Refresh metadata but never change the row's status.
|
||||||
|
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||||
# Already approved as a canvas Node → refresh props, skip pending row.
|
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||||
existing_node_q = await db.execute(
|
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||||
select(Node).where(Node.ieee_address == ieee)
|
inv.model = n.get("model") or inv.model
|
||||||
)
|
inv.vendor = n.get("vendor") or inv.vendor
|
||||||
existing_node = existing_node_q.scalar_one_or_none()
|
if n.get("lqi") is not None:
|
||||||
if existing_node:
|
inv.lqi = n.get("lqi")
|
||||||
existing_node.properties = merge_zwave_properties(
|
pending_updated += 1
|
||||||
existing_node.properties, props
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""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 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__)
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""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
|
||||||
@@ -311,6 +311,44 @@ async def test_approve_device(client: AsyncClient, headers, pending_device):
|
|||||||
assert inventory[0]["status"] == "approved"
|
assert inventory[0]["status"] == "approved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_no_dupe_same_ieee_same_design(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
"""Approving a device whose IEEE is already on the target design must reuse
|
||||||
|
the existing node, not create a duplicate (source of the crash bug)."""
|
||||||
|
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 == 200
|
||||||
|
assert res.json()["node_id"] == existing.id # reused, not new
|
||||||
|
|
||||||
|
nodes = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
||||||
|
).scalars().all()
|
||||||
|
assert len(nodes) == 1 # no duplicate created
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||||
node_payload = {
|
node_payload = {
|
||||||
|
|||||||
@@ -338,20 +338,36 @@ async def test_import_pending_endpoint_creates_zigbee_scan_run(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_creates_coordinator_and_pending(
|
async def test_persist_pending_import_coordinator_goes_to_pending(
|
||||||
db_session,
|
db_session,
|
||||||
) -> None:
|
) -> 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.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)
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
assert result.device_count == 3
|
assert result.device_count == 3
|
||||||
assert result.pending_created == 2
|
assert result.pending_created == 3 # coordinator included now
|
||||||
assert result.pending_updated == 0
|
assert result.pending_updated == 0
|
||||||
assert result.coordinator is not None
|
assert result.coordinator is None # not auto-placed
|
||||||
assert result.coordinator.ieee_address == "0xCOORD"
|
|
||||||
assert result.coordinator_already_existed is False
|
assert result.coordinator_already_existed is False
|
||||||
assert result.links_recorded == 2
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_idempotent_updates_existing(
|
async def test_persist_pending_import_idempotent_updates_existing(
|
||||||
@@ -366,8 +382,8 @@ async def test_persist_pending_import_idempotent_updates_existing(
|
|||||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
assert result.pending_created == 0
|
assert result.pending_created == 0
|
||||||
assert result.pending_updated == 2
|
assert result.pending_updated == 3 # coordinator upserts too
|
||||||
assert result.coordinator_already_existed is True
|
assert result.coordinator_already_existed is False
|
||||||
assert result.links_recorded == 2
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -389,12 +405,12 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
|
async def test_persist_pending_import_sets_coordinator_pending_fields(db_session) -> None:
|
||||||
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
|
"""Coordinator lands in pending carrying its vendor/model metadata."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
from app.db.models import Node
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||||
nodes_with_meta[0]["vendor"] = "TI"
|
nodes_with_meta[0]["vendor"] = "TI"
|
||||||
@@ -403,28 +419,30 @@ async def test_persist_pending_import_sets_coordinator_properties(db_session) ->
|
|||||||
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
||||||
|
|
||||||
coord = (
|
coord = (
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
||||||
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
assert coord.vendor == "TI"
|
||||||
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
assert coord.model == "CC2652"
|
||||||
# New zigbee props default to hidden — user opts in from the right panel.
|
assert coord.suggested_type == "zigbee_coordinator"
|
||||||
assert all(p["visible"] is False for p in coord.properties)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_skips_pending_for_approved_node(
|
async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
||||||
db_session,
|
db_session,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A device already approved as a canvas Node must not reappear in pending.
|
"""A device on canvas but missing its inventory row gets one backfilled
|
||||||
|
(status="approved"), so it shows in the inventory list. Node props still
|
||||||
Its properties must still be refreshed with the latest Vendor/Model/LQI.
|
refresh with the latest Vendor/Model/LQI.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
from app.db.models import Node, PendingDevice
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
# Simulate: router was approved earlier → exists as a canvas Node.
|
# 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(
|
approved = Node(
|
||||||
label="router_1",
|
label="router_1",
|
||||||
type="zigbee_router",
|
type="zigbee_router",
|
||||||
@@ -441,13 +459,15 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
|||||||
bumped[1]["lqi"] = 250 # new LQI from re-import
|
bumped[1]["lqi"] = 250 # new LQI from re-import
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
# No PendingDevice row was created for the approved router.
|
# An inventory row is backfilled as "approved" (it is on a canvas).
|
||||||
pendings = (
|
inv = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalar_one()
|
||||||
assert pendings == []
|
assert inv.status == "approved"
|
||||||
|
assert inv.suggested_type == "zigbee_router"
|
||||||
|
assert inv.device_subtype == "Router"
|
||||||
|
|
||||||
# Node properties got refreshed.
|
# Node properties got refreshed.
|
||||||
refreshed = (
|
refreshed = (
|
||||||
@@ -459,6 +479,37 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
|||||||
assert all(p["visible"] is False for p in refreshed.properties)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_revives_orphaned_approved_device(
|
async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||||
db_session,
|
db_session,
|
||||||
@@ -501,8 +552,8 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert revived.status == "pending"
|
assert revived.status == "pending"
|
||||||
# End device 0xE1 is brand new → created as pending; router was updated.
|
# Coordinator + end device 0xE1 are brand new → created; router was revived.
|
||||||
assert result.pending_created == 1
|
assert result.pending_created == 2
|
||||||
assert result.pending_updated == 1
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
# It is now visible to the Pending list (status filter == "pending").
|
# It is now visible to the Pending list (status filter == "pending").
|
||||||
@@ -511,7 +562,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
|||||||
select(PendingDevice).where(PendingDevice.status == "pending")
|
select(PendingDevice).where(PendingDevice.status == "pending")
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
|
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -591,15 +642,23 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||||
db_session,
|
db_session,
|
||||||
) -> None:
|
) -> 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 sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
from app.db.models import Node
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
# 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 = [dict(n) for n in _PENDING_NODES]
|
||||||
bumped[0]["vendor"] = "TI"
|
bumped[0]["vendor"] = "TI"
|
||||||
@@ -612,10 +671,53 @@ async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
|||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
assert keys["Vendor"] == "TI"
|
assert keys["Vendor"] == "TI"
|
||||||
assert keys["Model"] == "CC2652"
|
assert keys["Model"] == "CC2652"
|
||||||
# Newly added keys on re-import default to hidden.
|
|
||||||
by_key = {p["key"]: p for p in coord.properties}
|
by_key = {p["key"]: p for p in coord.properties}
|
||||||
assert by_key["Vendor"]["visible"] is False
|
assert by_key["Vendor"]["visible"] is False
|
||||||
assert by_key["Model"]["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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -297,18 +297,31 @@ async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_creates_coordinator_and_pending(db_session) -> None:
|
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.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)
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
assert result.device_count == 3
|
assert result.device_count == 3
|
||||||
assert result.pending_created == 2
|
assert result.pending_created == 3 # coordinator included now
|
||||||
assert result.pending_updated == 0
|
assert result.pending_updated == 0
|
||||||
assert result.coordinator is not None
|
assert result.coordinator is None # not auto-placed
|
||||||
assert result.coordinator.ieee_address == "zwave-0xh-1"
|
|
||||||
assert result.coordinator_already_existed is False
|
assert result.coordinator_already_existed is False
|
||||||
assert result.links_recorded == 2
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
||||||
@@ -319,8 +332,8 @@ async def test_persist_idempotent_updates_existing(db_session) -> None:
|
|||||||
bumped[1]["model"] = "ZW111"
|
bumped[1]["model"] = "ZW111"
|
||||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
assert result.pending_created == 0
|
assert result.pending_created == 0
|
||||||
assert result.pending_updated == 2
|
assert result.pending_updated == 3 # coordinator upserts too
|
||||||
assert result.coordinator_already_existed is True
|
assert result.coordinator_already_existed is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -339,26 +352,31 @@ async def test_persist_replaces_links(db_session) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_sets_coordinator_properties(db_session) -> None:
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
from app.db.models import Node
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
nodes = [dict(n) for n in _PENDING_NODES]
|
nodes = [dict(n) for n in _PENDING_NODES]
|
||||||
nodes[0]["vendor"] = "Aeotec"
|
nodes[0]["vendor"] = "Aeotec"
|
||||||
nodes[0]["model"] = "ZW090"
|
nodes[0]["model"] = "ZW090"
|
||||||
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
||||||
coord = (
|
coord = (
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
|
||||||
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
assert coord.vendor == "Aeotec"
|
||||||
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
|
assert coord.model == "ZW090"
|
||||||
assert all(p["visible"] is False for p in coord.properties)
|
assert coord.suggested_type == "zwave_coordinator"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_skips_pending_for_approved_node(db_session) -> None:
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
@@ -378,12 +396,13 @@ async def test_persist_skips_pending_for_approved_node(db_session) -> None:
|
|||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
pendings = (
|
inv = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalar_one()
|
||||||
assert pendings == []
|
assert inv.status == "approved"
|
||||||
|
assert inv.suggested_type == "zwave_router"
|
||||||
refreshed = (
|
refreshed = (
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
@@ -416,7 +435,8 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert revived.status == "pending"
|
assert revived.status == "pending"
|
||||||
assert result.pending_created == 1
|
# Coordinator + end device are brand new → created; router was revived.
|
||||||
|
assert result.pending_created == 2
|
||||||
assert result.pending_updated == 1
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -445,3 +465,38 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert still_hidden.status == "hidden"
|
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
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@
|
|||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
import type { NodeType, ServiceInfo } from '@/types'
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
@@ -657,7 +659,12 @@ interface DeviceCardProps {
|
|||||||
|
|
||||||
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||||
const source = inferSource(device)
|
const source = inferSource(device)
|
||||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const roleType = (device.suggested_type ?? 'generic') as NodeType
|
||||||
|
const Icon = TYPE_ICONS[roleType] ?? Circle
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
// Colour the role badge with the same accent the node uses on the canvas
|
||||||
|
// (from the active theme / style section), instead of a flat grey.
|
||||||
|
const roleColor = resolveNodeColors({ type: roleType, custom_colors: undefined }, activeTheme).border
|
||||||
const label = deviceLabel(device)
|
const label = deviceLabel(device)
|
||||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
||||||
const sourceLabel =
|
const sourceLabel =
|
||||||
@@ -734,7 +741,10 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
|||||||
{sourceLabel}
|
{sourceLabel}
|
||||||
</span>
|
</span>
|
||||||
{device.suggested_type && (
|
{device.suggested_type && (
|
||||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
<span
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${roleColor}22`, color: roleColor }}
|
||||||
|
>
|
||||||
{device.suggested_type}
|
{device.suggested_type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -153,6 +153,18 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('colours the role badge with the node-type accent, not flat grey', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_ZWAVE] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
const card = await waitFor(() => screen.getByTestId('pending-card-dev-c'))
|
||||||
|
// zwave_router accent from the default theme = #e3b341 (amber), applied to
|
||||||
|
// both the text colour and a translucent background.
|
||||||
|
const badge = within(card).getByText('zwave_router')
|
||||||
|
// #e3b341 → rgb(227, 179, 65) once jsdom normalises the inline colour.
|
||||||
|
expect(badge).toHaveStyle({ color: 'rgb(227, 179, 65)' })
|
||||||
|
expect(badge.className).not.toContain('text-muted-foreground')
|
||||||
|
})
|
||||||
|
|
||||||
it('filters by source (zwave only)', async () => {
|
it('filters by source (zwave only)', async () => {
|
||||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
|||||||
Reference in New Issue
Block a user