fix(zigbee): default IEEE/Vendor/Model/LQI props to hidden, preserve user visibility
- New zigbee props (approve + first-time re-import) ship with visible=false so the canvas card stays clean. User opts in from the right panel. - On re-import of an already-approved node, merge instead of overwrite: keys already present keep their visible flag (and any user-edited value is replaced with the freshly imported one), brand-new keys are appended hidden. Non-zigbee custom properties are preserved untouched.
This commit is contained in:
@@ -23,7 +23,12 @@ from app.schemas.zigbee import (
|
|||||||
ZigbeeTestConnectionRequest,
|
ZigbeeTestConnectionRequest,
|
||||||
ZigbeeTestConnectionResponse,
|
ZigbeeTestConnectionResponse,
|
||||||
)
|
)
|
||||||
from app.services.zigbee_service import build_zigbee_properties, fetch_networkmap, test_mqtt_connection
|
from app.services.zigbee_service import (
|
||||||
|
build_zigbee_properties,
|
||||||
|
fetch_networkmap,
|
||||||
|
merge_zigbee_properties,
|
||||||
|
test_mqtt_connection,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -150,7 +155,9 @@ async def _persist_pending_import(
|
|||||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
||||||
existing_node = existing.scalar_one_or_none()
|
existing_node = existing.scalar_one_or_none()
|
||||||
if existing_node:
|
if existing_node:
|
||||||
existing_node.properties = props
|
existing_node.properties = merge_zigbee_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
coordinator_out = ZigbeeCoordinatorOut(
|
coordinator_out = ZigbeeCoordinatorOut(
|
||||||
id=existing_node.id,
|
id=existing_node.id,
|
||||||
label=existing_node.label,
|
label=existing_node.label,
|
||||||
@@ -183,7 +190,9 @@ async def _persist_pending_import(
|
|||||||
)
|
)
|
||||||
existing_node = existing_node_q.scalar_one_or_none()
|
existing_node = existing_node_q.scalar_one_or_none()
|
||||||
if existing_node:
|
if existing_node:
|
||||||
existing_node.properties = props
|
existing_node.properties = merge_zigbee_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -68,19 +68,44 @@ def build_zigbee_properties(
|
|||||||
|
|
||||||
Only includes a row when the value is non-empty. Shape matches the
|
Only includes a row when the value is non-empty. Shape matches the
|
||||||
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
|
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]] = []
|
props: list[dict[str, Any]] = []
|
||||||
if ieee:
|
if ieee:
|
||||||
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": True})
|
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
|
||||||
if vendor:
|
if vendor:
|
||||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": True})
|
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||||
if model:
|
if model:
|
||||||
props.append({"key": "Model", "value": model, "icon": None, "visible": True})
|
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||||
if lqi is not None:
|
if lqi is not None:
|
||||||
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": True})
|
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
|
||||||
return props
|
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:
|
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||||
"""Map a Z2M device type string to a homelable node type."""
|
"""Map a Z2M device type string to a homelable node type."""
|
||||||
mapping = {
|
mapping = {
|
||||||
|
|||||||
@@ -407,6 +407,8 @@ async def test_persist_pending_import_sets_coordinator_properties(db_session) ->
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
||||||
|
# New zigbee props default to hidden — user opts in from the right panel.
|
||||||
|
assert all(p["visible"] is False for p in coord.properties)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -453,6 +455,53 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||||
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
|
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_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
|
@pytest.mark.asyncio
|
||||||
@@ -477,6 +526,10 @@ 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}
|
||||||
|
assert by_key["Vendor"]["visible"] is False
|
||||||
|
assert by_key["Model"]["visible"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export function buildZigbeeProperties(input: {
|
|||||||
lqi?: number | null
|
lqi?: number | null
|
||||||
}): NodeProperty[] {
|
}): NodeProperty[] {
|
||||||
const props: NodeProperty[] = []
|
const props: NodeProperty[] = []
|
||||||
if (input.ieee_address) props.push({ key: 'IEEE', value: input.ieee_address, icon: null, visible: true })
|
if (input.ieee_address) props.push({ key: 'IEEE', value: input.ieee_address, icon: null, visible: false })
|
||||||
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: true })
|
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
|
||||||
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: true })
|
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
|
||||||
if (input.lqi != null) props.push({ key: 'LQI', value: String(input.lqi), icon: null, visible: true })
|
if (input.lqi != null) props.push({ key: 'LQI', value: String(input.lqi), icon: null, visible: false })
|
||||||
return props
|
return props
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user