fix: match scanned devices to canvas nodes by ip-token and mac (#258)

Node.ip stores several comma-separated addresses once a user adds an
IPv6 (e.g. "fe80::1, 192.168.1.5"). All placement/inventory matching did
exact string equality on that field, so a device scanned as the plain
IPv4 looked absent from the canvas: canvas_count stayed 0, the "In N
canvas" badge and hide-on-canvas button vanished, and bulk-approve
re-placed a duplicate node.

Match per-address instead, and add MAC (a stable identifier immune to IP
edits) as a cumulative match key alongside ieee/ip:
- _canvas_correlation indexes ip per token + by_mac
- bulk_approve skip detection tokenizes ip + tracks mac
- find_duplicate_node narrows with Node.ip.contains then confirms per
  token in Python (guards the 10.0.0.4 / 10.0.0.40 substring false match)

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-09 14:41:53 +02:00
parent 2bebe8f42d
commit 2d3b646e45
3 changed files with 192 additions and 36 deletions
+40 -14
View File
@@ -29,6 +29,15 @@ 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,
@@ -49,31 +58,48 @@ async def find_duplicate_node(
(: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)
if ip:
conds.append(Node.ip == ip)
# 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
existing = (
candidates = (
await db.execute(
select(Node).where(Node.design_id == design_id, or_(*conds))
)
).scalars().first()
if existing is None:
).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
# Report whichever field actually matched (ieee > ip > mac when several do).
match: str
value: str | None
if ieee and existing.ieee_address == ieee:
match, value = "ieee", existing.ieee_address
elif ip and existing.ip == ip:
match, value = "ip", existing.ip
else:
match, value = "mac", existing.mac
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,