Refactor contacts to emit individual MQTT events per contact

Instead of sending all contacts in one MQTT message, the interface
now splits the device's contacts response into individual 'contact'
events. This is more consistent with other event patterns and makes
the collector simpler.

Interface changes:
- Add _publish_contacts() to split contacts dict into individual events
- Publish each contact as 'contact' event (not 'contacts')

Collector changes:
- Rename handle_contacts to handle_contact for single contact
- Simplify handler to process one contact per message
- Register handler for 'contact' events
This commit is contained in:
Claude
2025-12-04 14:34:05 +00:00
parent cf633f9f44
commit 1d6a9638a1
3 changed files with 94 additions and 55 deletions
@@ -19,7 +19,7 @@ def register_all_handlers(subscriber: "Subscriber") -> None:
)
from meshcore_hub.collector.handlers.trace import handle_trace_data
from meshcore_hub.collector.handlers.telemetry import handle_telemetry
from meshcore_hub.collector.handlers.contacts import handle_contacts
from meshcore_hub.collector.handlers.contacts import handle_contact
from meshcore_hub.collector.handlers.event_log import handle_event_log
# Persisted events with specific handlers
@@ -28,7 +28,7 @@ def register_all_handlers(subscriber: "Subscriber") -> None:
subscriber.register_handler("channel_msg_recv", handle_channel_message)
subscriber.register_handler("trace_data", handle_trace_data)
subscriber.register_handler("telemetry_response", handle_telemetry)
subscriber.register_handler("contacts", handle_contacts)
subscriber.register_handler("contact", handle_contact) # Individual contact events
# Informational events (logged only)
subscriber.register_handler("send_confirmed", handle_event_log)
+44 -53
View File
@@ -1,4 +1,4 @@
"""Handler for contacts sync events."""
"""Handler for contact sync events."""
import logging
from datetime import datetime, timezone
@@ -20,73 +20,64 @@ NODE_TYPE_MAP = {
}
def handle_contacts(
def handle_contact(
public_key: str,
event_type: str,
payload: dict[str, Any],
db: DatabaseManager,
) -> None:
"""Handle a contacts sync event.
"""Handle a single contact event.
Upserts all contacts in the contacts list.
Upserts a contact into the nodes table.
Args:
public_key: Receiver node's public key (from MQTT topic)
event_type: Event type name
payload: Contacts payload (array of contact objects from device)
payload: Single contact object with fields:
- public_key: Contact's public key
- adv_name: Advertised name
- type: Numeric node type (0=none, 1=chat, 2=repeater, 3=room)
db: Database manager
"""
contacts = payload.get("contacts", [])
if not contacts:
logger.debug("Empty contacts list received")
contact_key = payload.get("public_key")
if not contact_key:
logger.warning("Contact event missing public_key field")
return
# Device uses 'adv_name' for the advertised name
name = payload.get("adv_name") or payload.get("name")
# Device uses numeric 'type' field, convert to string
raw_type = payload.get("type")
if raw_type is not None:
node_type: str | None = NODE_TYPE_MAP.get(raw_type, str(raw_type))
else:
node_type = payload.get("node_type")
now = datetime.now(timezone.utc)
created_count = 0
updated_count = 0
with db.session_scope() as session:
for contact in contacts:
contact_key = contact.get("public_key")
if not contact_key:
continue
# Find or create node
node_query = select(Node).where(Node.public_key == contact_key)
node = session.execute(node_query).scalar_one_or_none()
# Device uses 'adv_name' for the advertised name
name = contact.get("adv_name") or contact.get("name")
# Device uses numeric 'type' field, convert to string
raw_type = contact.get("type")
if raw_type is not None:
node_type = NODE_TYPE_MAP.get(raw_type, str(raw_type))
else:
node_type = contact.get("node_type")
# Find or create node
node_query = select(Node).where(Node.public_key == contact_key)
node = session.execute(node_query).scalar_one_or_none()
if node:
# Update existing node - always update name if we have one
if name and name != node.name:
logger.debug(f"Updating node {contact_key[:12]}... name: {name}")
node.name = name
if node_type and not node.adv_type:
node.adv_type = node_type
node.last_seen = now
updated_count += 1
else:
# Create new node
node = Node(
public_key=contact_key,
name=name,
adv_type=node_type,
first_seen=now,
last_seen=now,
)
session.add(node)
created_count += 1
logger.debug(f"Created node {contact_key[:12]}... name: {name}")
logger.info(
f"Processed contacts sync: {created_count} new, {updated_count} updated"
)
if node:
# Update existing node - always update name if we have one
if name and name != node.name:
logger.debug(f"Updating node {contact_key[:12]}... name: {name}")
node.name = name
if node_type and not node.adv_type:
node.adv_type = node_type
node.last_seen = now
logger.debug(f"Updated contact: {contact_key[:12]}... ({name})")
else:
# Create new node
node = Node(
public_key=contact_key,
name=name,
adv_type=node_type,
first_seen=now,
last_seen=now,
)
session.add(node)
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
+48
View File
@@ -117,6 +117,11 @@ class Receiver:
# Convert event type to MQTT topic name
event_name = event_type.value
# Special handling for CONTACTS: split into individual messages
if event_type == EventType.CONTACTS:
self._publish_contacts(payload)
return
# Publish to MQTT
self.mqtt.publish_event(
self.device.public_key,
@@ -129,6 +134,49 @@ class Receiver:
except Exception as e:
logger.error(f"Failed to publish event to MQTT: {e}")
def _publish_contacts(self, payload: dict[str, Any]) -> None:
"""Publish each contact as a separate MQTT message.
The device returns contacts as a dict keyed by public_key.
We split this into individual 'contact' events for cleaner processing.
Args:
payload: Dict of contacts keyed by public_key
"""
if not self.device.public_key:
logger.warning("Cannot publish contacts: device public key not available")
return
# Handle both formats:
# - Dict keyed by public_key (real device)
# - Dict with "contacts" array (mock device)
if "contacts" in payload:
contacts = payload["contacts"]
else:
contacts = list(payload.values())
if not contacts:
logger.debug("Empty contacts list received")
return
device_key = self.device.public_key # Capture for type narrowing
count = 0
for contact in contacts:
if not isinstance(contact, dict):
continue
try:
self.mqtt.publish_event(
device_key,
"contact", # Use singular 'contact' for individual events
contact,
)
count += 1
except Exception as e:
logger.error(f"Failed to publish contact event: {e}")
logger.info(f"Published {count} contact events to MQTT")
def start(self) -> None:
"""Start the receiver."""
logger.info("Starting RECEIVER mode")