feat(zigbee): import to pending section with edge persistence
Coordinator auto-approves to a canvas Node; routers/end devices land in pending_devices keyed by IEEE. Discovered parent->child edges are stored in pending_device_links so that approving a pending device later auto-creates the Edge once both endpoints exist as canvas Nodes. - new POST /api/v1/zigbee/import-pending (default mode in modal) - new pending_device_links table; ieee_address on nodes + pending_devices - pending_devices.ip migrated to nullable (table rebuild on existing DBs) - approve / bulk-approve return auto-created edges; sidebar pushes them into the canvas store with bottom -> top-t handles - ZigbeeImportModal: radio toggle pending vs canvas; reset on close - PendingDeviceModal: zigbee badge, IEEE/LQI/vendor/model rows, services hidden for zigbee - Sidebar pending row: ZIG source badge, LQI badge, friendly_name fallback - SearchBar: null-safe IP, also searches friendly_name and ieee_address - Tooltip trigger uses asChild to avoid nested-button hydration error
This commit is contained in:
@@ -264,6 +264,167 @@ async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: di
|
||||
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_creates_coordinator_and_pending(
|
||||
client: AsyncClient, headers: dict
|
||||
) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["device_count"] == 3
|
||||
assert data["pending_created"] == 2 # router + enddevice
|
||||
assert data["pending_updated"] == 0
|
||||
assert data["coordinator"] is not None
|
||||
assert data["coordinator"]["ieee_address"] == "0xCOORD"
|
||||
assert data["coordinator_already_existed"] is False
|
||||
assert data["links_recorded"] == 2
|
||||
|
||||
pending = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending.status_code == 200
|
||||
rows = pending.json()
|
||||
ieees = {r["ieee_address"] for r in rows}
|
||||
assert ieees == {"0xR1", "0xE1"}
|
||||
router = next(r for r in rows if r["ieee_address"] == "0xR1")
|
||||
assert router["model"] == "CC2530"
|
||||
assert router["lqi"] == 220
|
||||
assert router["device_subtype"] == "Router"
|
||||
assert router["discovery_source"] == "zigbee"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_idempotent_updates_existing(
|
||||
client: AsyncClient, headers: dict
|
||||
) -> None:
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
|
||||
await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["lqi"] = 99
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
# second call: returns the bumped data
|
||||
mock_fetch.return_value = (bumped, _PENDING_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["pending_created"] == 0
|
||||
assert data["pending_updated"] == 2
|
||||
assert data["coordinator_already_existed"] is True
|
||||
assert data["links_recorded"] == 2
|
||||
|
||||
pending = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
router = next(r for r in pending.json() if r["ieee_address"] == "0xR1")
|
||||
assert router["lqi"] == 99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_replaces_links(
|
||||
client: AsyncClient, headers: dict, db_session
|
||||
) -> None:
|
||||
"""Re-importing wipes old zigbee links and inserts only the fresh set."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import PendingDeviceLink
|
||||
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
|
||||
await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
|
||||
mock_fetch.return_value = (_PENDING_NODES[:2], new_edges)
|
||||
await client.post(
|
||||
"/api/v1/zigbee/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
result = await db_session.execute(select(PendingDeviceLink))
|
||||
links = result.scalars().all()
|
||||
assert len(links) == 1
|
||||
assert (links[0].source_ieee, links[0].target_ieee) == ("0xCOORD", "0xR1")
|
||||
|
||||
|
||||
@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:
|
||||
|
||||
Reference in New Issue
Block a user