Files
meshcore-bot/modules/contact_events.py
agessaman f5628acf80 refactor(message_handler): one mesh-graph capture guard and one contact-capacity check
_mesh_graph_capturing() replaces the three copies of `hasattr(self.bot, "mesh_graph") and self.bot.mesh_graph and self.bot.mesh_graph.capture_enabled`, evaluated in the same order after the same preceding conditions.

_manage_contact_capacity(contact_name, after_add) replaces the two copies of the near-limit check in handle_new_contact (device mode, and bot mode after an add attempt). The WARNING and INFO lines keep their exact text, including the " after add" and " after add attempt" suffixes in bot mode; new tests pin all four lines and the cleanup call, and pass against the code before this commit.
2026-10-03 09:59:19 -07:00

573 lines
28 KiB
Python

"""Contact and advert events, mixed into MessageHandler.
NEW_CONTACT handling (route from the contact's own advert, the add-to-radio gate), advert packet tracking and the path encoding a contact is stored with."""
import asyncio
import copy
import time
from typing import Any
from .neighbors_discovery import upsert_zero_hop_observed_path_via_manager
from .security_utils import sanitize_name
from .utils import encode_path_len_byte
class ContactEventsMixin:
"""Mixed into MessageHandler."""
_advert_rf: Any
_mesh_graph_capturing: Any
_new_contact_adds: Any
_store_observed_path: Any
_update_mesh_graph_from_advert: Any
_viewer_bridge: Any
bot: Any
logger: Any
parse_advert: Any
rf_data_timeout: Any
async def _process_advertisement_packet(
self, packet_info: dict[str, Any], metadata: dict[str, Any] | None = None
) -> None:
"""Process advertisement packets for complete repeater tracking.
Extracts node information, location data, and routing path from
advertisement packets and updates the repeater database.
Args:
packet_info: Dictionary containing decoded packet information.
metadata: Optional metadata dictionary with signal metrics.
"""
try:
# Check if this is an advertisement packet
if (
packet_info.get("payload_type") == "ADVERT"
or packet_info.get("payload_type_name") == "ADVERT"
or packet_info.get("type") == "advert"
):
self.logger.debug(f"Processing advertisement packet: {packet_info}")
# Parse the advert payload if we have it
advert_data = {}
if "payload_hex" in packet_info:
try:
payload_bytes = bytes.fromhex(packet_info["payload_hex"])
parsed_advert = self.parse_advert(payload_bytes)
if parsed_advert:
advert_data = parsed_advert
self.logger.info(
f"✅ Parsed ADVERT: {sanitize_name(advert_data.get('mode', 'Unknown'))} - {sanitize_name(advert_data.get('name', 'No name'))}"
)
except Exception as e:
self.logger.warning(f"Failed to parse ADVERT payload: {e}")
# Fallback to basic information if parsing failed
if not advert_data:
advert_data = {
"public_key": packet_info.get("sender_id", ""),
"name": packet_info.get("name", packet_info.get("adv_name", "Unknown")),
"mode": "Unknown",
}
# Add advert data to packet_info for web viewer
if advert_data:
packet_info["advert_name"] = advert_data.get("name")
packet_info["advert_mode"] = advert_data.get("mode")
packet_info["advert_public_key"] = advert_data.get("public_key")
# Extract signal information from metadata
signal_info = {}
if metadata:
signal_info.update(metadata)
# Add hop count if available
if "hops" in packet_info:
signal_info["hops"] = packet_info["hops"]
# Extract packet_hash and path information if available (from routing_info or packet_info)
packet_hash = None
out_path = ""
out_path_len = -1
if "routing_info" in packet_info and packet_info["routing_info"]:
routing_info = packet_info["routing_info"]
packet_hash = routing_info.get("packet_hash")
# Extract path information from routing_info
path_hex = routing_info.get("path_hex", "")
path_length = routing_info.get("path_length", 0)
if path_hex and path_length > 0:
out_path = path_hex
out_path_len = path_length
elif path_length == 0:
# Direct connection
out_path = ""
out_path_len = 0
elif "packet_hash" in packet_info:
packet_hash = packet_info["packet_hash"]
# Also check packet_info directly for path information (fallback)
if out_path_len == -1:
if "path_hex" in packet_info:
out_path = packet_info.get("path_hex", "")
out_path_len = packet_info.get("path_len", -1)
elif "path_len" in packet_info:
out_path_len = packet_info.get("path_len", -1)
if out_path_len == 0:
out_path = ""
# Add path information to advert_data so it gets saved to the database
if out_path_len >= 0:
advert_data["out_path"] = out_path
advert_data["out_path_len"] = out_path_len
advert_data["out_bytes_per_hop"] = packet_info.get("bytes_per_hop", 1)
# Update mesh graph with edges from the advert path (one edge per hop).
# This can trigger many send_mesh_edge_update() calls in quick succession;
# if the web viewer is down, that produces a wave of connection-refused logs.
path_byte_length = packet_info.get("path_byte_length") or (len(out_path) // 2 if out_path else 0)
if out_path and out_path_len > 0 and self._mesh_graph_capturing():
self._update_mesh_graph_from_advert(advert_data, out_path, path_byte_length, packet_info)
# Store complete path in observed_paths table. Empty-path (direct RF)
# adverts are stored too — those are true one-hop neighbours.
if out_path_len == 0 and advert_data.get("public_key"):
upsert_zero_hop_observed_path_via_manager(
getattr(self.bot, "db_manager", None),
advert_data["public_key"],
self.logger,
snr=signal_info.get("snr") if signal_info else None,
rssi=(
signal_info.get("rssi", signal_info.get("signal_strength"))
if signal_info
else None
),
bytes_per_hop=packet_info.get("bytes_per_hop", 1) or 1,
packet_hash=packet_hash,
update_rssi=True,
)
elif out_path and out_path_len > 0:
self._store_observed_path(
advert_data,
out_path,
path_byte_length,
"advert",
packet_hash=packet_hash,
bytes_per_hop=packet_info.get("bytes_per_hop", 1),
)
# Track this advertisement in the complete database
if hasattr(self.bot, "repeater_manager"):
# Track all advertisements regardless of type
track_result = await self.bot.repeater_manager.track_contact_advertisement(
advert_data, signal_info, packet_hash=packet_hash
)
if track_result.ok:
# Log rich advert information
mode = advert_data.get("mode", "Unknown")
name = advert_data.get("name", "No name")
location = ""
if "lat" in advert_data and "lon" in advert_data:
# Try to get resolved location from database if available
try:
if hasattr(self.bot, "repeater_manager"):
# Look up the contact to get resolved location
public_key = advert_data.get("public_key")
if public_key:
contact_query = self.bot.db_manager.execute_query(
"SELECT city, state, country FROM complete_contact_tracking WHERE public_key = ?",
(public_key,),
)
if contact_query:
contact = contact_query[0]
city = contact.get("city")
state = contact.get("state")
if city and state:
location = f" at {city}, {state}"
elif city:
location = f" at {city}"
else:
# Fallback to coordinates if no resolved location
location = f" at {advert_data['lat']:.4f},{advert_data['lon']:.4f}"
else:
# No contact found yet, use coordinates
location = f" at {advert_data['lat']:.4f},{advert_data['lon']:.4f}"
else:
# No public key, use coordinates
location = f" at {advert_data['lat']:.4f},{advert_data['lon']:.4f}"
else:
# No repeater manager, use coordinates
location = f" at {advert_data['lat']:.4f},{advert_data['lon']:.4f}"
except Exception as e:
# If lookup fails, fallback to coordinates
self.logger.debug(f"Could not get resolved location for logging: {e}")
location = f" at {advert_data['lat']:.4f},{advert_data['lon']:.4f}"
# Show hop count in log
hop_count = signal_info.get("hops", 0)
hop_info = f" ({hop_count} hop{'s' if hop_count != 1 else ''})" if hop_count is not None else ""
self.logger.info(f"📡 Tracked {mode}: {name}{location}{hop_info}")
else:
self.logger.warning(
f"Failed to track contact advertisement: {sanitize_name(advert_data.get('name', 'Unknown'))}"
)
except Exception as e:
self.logger.error(f"Error processing advertisement packet: {e}")
def _ensure_contact_meshcore_path_encoding(self, contact_data: dict[str, Any]) -> None:
"""If out_path_len is set but out_path_hash_mode is still flood (-1), rebuild wire fields.
meshcore update_contact uses out_path_len | (out_path_hash_mode << 6); hash_mode -1 with
non-negative hop count produces a negative int and OverflowError on unsigned to_bytes.
"""
try:
hash_mode = int(contact_data.get("out_path_hash_mode", 0))
except (TypeError, ValueError):
return
if hash_mode != -1:
return
opl: int | None
raw_opl = contact_data.get("out_path_len")
try:
opl = None if raw_opl is None else int(raw_opl)
except (TypeError, ValueError):
opl = None
bph_raw = contact_data.get("out_bytes_per_hop", 1) or 1
try:
bph = int(bph_raw)
except (TypeError, ValueError):
bph = 1
if bph not in (1, 2, 3):
bph = 1
# Some NEW_CONTACT payloads omit out_path_len but include out_path + bytes_per_hop.
# Derive hop count here so meshcore doesn't combine a non-flood path with hash_mode=-1.
if opl is None:
out_path_hex = contact_data.get("out_path") or ""
if not isinstance(out_path_hex, str) or not out_path_hex:
return
if (len(out_path_hex) % 2) != 0:
return
path_bytes = len(out_path_hex) // 2
if path_bytes <= 0:
return
if (path_bytes % bph) == 0:
opl = path_bytes // bph
else:
opl = path_bytes
if opl < 0 or opl == -1:
return
try:
pb = encode_path_len_byte(opl, bph)
except ValueError:
pb = encode_path_len_byte(min(opl, 0x3F), 1)
contact_data["out_path_hash_mode"] = (pb >> 6) & 0x03
contact_data["out_path_len"] = pb & 0x3F
def _remember_advert_rf(
self,
packet_info: dict[str, Any],
routing_info: dict[str, Any],
packet_hash: str | None,
signal_info: dict[str, Any],
current_time: float,
) -> None:
"""Record an advert heard on RF for handle_new_contact.
Called before the advert is processed, which can yield, so a NEW_CONTACT
handled meanwhile still finds it. An ADVERT payload starts with the
sender's 32-byte public key and its 4-byte advert timestamp.
"""
payload_hex = packet_info.get("payload_hex")
if not isinstance(payload_hex, str) or len(payload_hex) < 72:
return
try:
advert_timestamp = int.from_bytes(bytes.fromhex(payload_hex[64:72]), "little")
except ValueError:
return
self._advert_rf.append({
"timestamp": current_time,
"public_key": payload_hex[:64].lower(),
"advert_timestamp": advert_timestamp,
"routing_info": routing_info,
"packet_hash": packet_hash,
"snr": signal_info.get("snr"),
"rssi": signal_info.get("rssi"),
})
cutoff = current_time - self.rf_data_timeout
self._advert_rf = [entry for entry in self._advert_rf if entry["timestamp"] >= cutoff][-256:]
def _find_advert_rf_data(self, public_key: str, advert_timestamp: Any = None) -> dict[str, Any] | None:
"""The first copy heard of ``public_key``'s advert, or None.
With ``advert_timestamp`` (NEW_CONTACT's ``last_advert``, 0 included) only
that advert matches; without it, the most recent one. Copies of one advert heard over
different paths share its timestamp; the first is the one the device acted on.
"""
if not public_key:
return None
key = public_key.lower()
now = time.time()
matches = [
entry
for entry in self._advert_rf
if entry["public_key"] == key and now - entry["timestamp"] < self.rf_data_timeout
]
if isinstance(advert_timestamp, int) and not isinstance(advert_timestamp, bool) and advert_timestamp >= 0:
matches = [entry for entry in matches if entry["advert_timestamp"] == advert_timestamp]
elif matches:
newest = max(matches, key=lambda entry: entry["timestamp"])["advert_timestamp"]
matches = [entry for entry in matches if entry["advert_timestamp"] == newest]
if not matches:
return None
return min(matches, key=lambda entry: entry["timestamp"])
def _release_new_contact_add(self, public_key: str, packet_hash: str | None) -> None:
"""Let a later NEW_CONTACT for this advert try again after a failed add."""
self._new_contact_adds.pop((public_key, packet_hash or ""), None)
def _claim_new_contact_add(self, public_key: str, packet_hash: str | None) -> bool:
"""False when a NEW_CONTACT for this advert packet already added the contact."""
if not packet_hash or packet_hash == "0000000000000000":
return True
key = (public_key, packet_hash)
if key in self._new_contact_adds:
return False
self._new_contact_adds[key] = None
while len(self._new_contact_adds) > 256:
del self._new_contact_adds[next(iter(self._new_contact_adds))]
return True
async def _manage_contact_capacity(self, contact_name: str, after_add: bool = False) -> None:
"""Clean up the device contact list when it is near its limit; log either way."""
status = await self.bot.repeater_manager.get_contact_list_status()
if status and status.get("is_near_limit", False):
self.logger.warning(
"Contact list near limit (%.1f%%) — managing capacity" + (" after add" if after_add else ""),
status["usage_percentage"],
)
await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True)
else:
self.logger.info(
"Companion %s — contact list has adequate space" + (" after add attempt" if after_add else ""),
contact_name,
)
async def handle_new_contact(self, event: Any, metadata: dict[str, Any] | None = None) -> None:
"""Handle NEW_CONTACT events for automatic contact management"""
try:
# Copy payload immediately to avoid segfault if event is freed
# Make a deep copy to ensure we have all the data we need
if hasattr(event, "payload"):
contact_data = copy.deepcopy(event.payload)
else:
# Fallback: try to copy the event itself if it's a dict-like object
contact_data = copy.deepcopy(event) if isinstance(event, dict) else None
if not contact_data:
self.logger.warning("NEW_CONTACT event has no payload data")
return
self.logger.debug(f"🔍 NEW_CONTACT EVENT RECEIVED: {event}")
# Get contact details
contact_name = sanitize_name(contact_data.get("name", contact_data.get("adv_name", "Unknown")))
public_key = contact_data.get("public_key", "")
self.logger.info(f"Processing new contact: {contact_name} (key: {public_key[:16]}...)")
# Extract additional signal information from the event
signal_info = {}
if metadata:
signal_info.update(metadata)
# Take the route, signal data and packet_hash from this contact's own ADVERT
# packet. Its mesh-graph edges and observed path were already recorded when
# the packet itself was processed, so they are not recorded again here.
# Only collect RSSI/SNR for zero-hop (direct) advertisements
packet_hash = None
try:
rf_entry = self._find_advert_rf_data(public_key, contact_data.get("last_advert"))
if rf_entry:
routing_info = rf_entry["routing_info"]
# Extract packet_hash if available
packet_hash = routing_info.get("packet_hash") or rf_entry.get("packet_hash")
# Extract path information from routing_info
path_hex = routing_info.get("path_hex", "")
path_length = routing_info.get("path_length", 0)
# Add path information to contact_data if not already present
if "out_path" not in contact_data or not contact_data.get("out_path"):
if path_hex and path_length > 0:
contact_data["out_path"] = path_hex
contact_data["out_bytes_per_hop"] = routing_info.get("bytes_per_hop", 1) or 1
bph = contact_data["out_bytes_per_hop"]
pb = routing_info.get("path_len_byte")
if pb is None or pb == 255:
try:
pb = encode_path_len_byte(path_length, bph)
except ValueError:
pb = encode_path_len_byte(path_length, 1)
contact_data["out_path_hash_mode"] = (pb >> 6) & 0x03
contact_data["out_path_len"] = pb & 0x3F
elif path_length == 0:
contact_data["out_path"] = ""
contact_data["out_path_len"] = 0
contact_data["out_path_hash_mode"] = 0
# Only collect signal data for direct (zero-hop) advertisements
if path_length == 0:
# Direct advertisement - collect signal data
if "snr" in rf_entry:
signal_info["snr"] = rf_entry["snr"]
if "rssi" in rf_entry:
signal_info["rssi"] = rf_entry["rssi"]
signal_info["hops"] = 0
self.logger.debug(
f"📡 Direct advertisement - collecting signal data: SNR={rf_entry.get('snr')}, RSSI={rf_entry.get('rssi')}"
)
else:
# Multi-hop advertisement - only collect hop count, not signal data
signal_info["hops"] = path_length
self.logger.debug(
f"📡 Multi-hop advertisement ({path_length} hops) - skipping signal data collection"
)
except Exception as e:
self.logger.debug(f"Could not correlate RF data: {e}")
# Log captured signal information
if signal_info:
self.logger.info(f"📡 Signal data: {signal_info}")
else:
self.logger.info("📡 No signal data available")
# Check if this is a repeater or companion
if hasattr(self.bot, "repeater_manager"):
is_repeater = self.bot.repeater_manager._is_repeater_device(contact_data)
existing_tracking = self.bot.repeater_manager.get_tracked_contact_row(public_key)
already_on_device = self.bot.repeater_manager.is_contact_on_device(public_key)
known_contact = already_on_device or existing_tracking is not None
if is_repeater:
# REPEATER: Track directly in SQLite database (no device contact list)
if known_contact:
self.logger.info(f"📡 Known repeater advert: {contact_name} - tracking in database only")
else:
self.logger.info(f"📡 New repeater discovered: {contact_name} - tracking in database only")
# Track repeater in complete database with signal info
await self.bot.repeater_manager.track_contact_advertisement(
contact_data, signal_info, packet_hash=packet_hash
)
# Notify web viewer of new node
if viewer := self._viewer_bridge():
try:
node_data = {
"public_key": public_key,
"prefix": public_key[: self.bot.prefix_hex_chars].lower() if public_key else "",
"name": contact_name,
"role": "repeater",
}
viewer.send_mesh_node_update(node_data)
except Exception as e:
self.logger.debug(f"Failed to notify web viewer of new node: {e}")
# Check if auto-purge is needed (run after tracking to ensure data is captured)
await self.bot.repeater_manager.check_and_auto_purge()
self.logger.info(f"✅ Repeater {contact_name} tracked in database - not added to device contacts")
return
else:
# COMPANION: track in DB; device add behaviour depends on auto_manage_contacts
auto_manage_setting = self.bot.config.get("Bot", "auto_manage_contacts", fallback="device").lower()
if known_contact:
self.logger.info(
"👤 Known companion advert: %s — auto_manage_contacts=%s",
contact_name,
auto_manage_setting,
)
else:
self.logger.info(
"👤 New companion discovered: %s — auto_manage_contacts=%s",
contact_name,
auto_manage_setting,
)
await self.bot.repeater_manager.track_contact_advertisement(
contact_data, signal_info, packet_hash=packet_hash
)
if auto_manage_setting == "false":
self.logger.info(
"Manual mode — companion %s tracked in database only (not added to device)",
contact_name,
)
elif auto_manage_setting == "device":
self.logger.info(
"Device mode — companion %s tracked; firmware handles addition; bot may manage capacity",
contact_name,
)
await self._manage_contact_capacity(contact_name)
elif auto_manage_setting == "bot":
# One add per advert: NEW_CONTACT can repeat for the same packet. The
# tracking result can't tell, since the advert packet itself was
# usually tracked first. Without a packet_hash every event adds.
if not self._claim_new_contact_add(public_key, packet_hash):
self.logger.debug(
"Skipping add_companion — duplicate packet_hash for %s (already tracked)",
contact_name,
)
else:
self.logger.info(
"Bot mode — adding companion %s to device with capacity management",
contact_name,
)
try:
self._ensure_contact_meshcore_path_encoding(contact_data)
ok = await self.bot.repeater_manager.add_companion_from_contact_data(
contact_data, contact_name, public_key
)
if not ok:
self._release_new_contact_add(public_key, packet_hash)
self.logger.warning(
"Failed to add companion contact %s to device after managed add/retry",
contact_name,
)
except asyncio.CancelledError:
self._release_new_contact_add(public_key, packet_hash)
raise
except Exception as e:
self._release_new_contact_add(public_key, packet_hash)
self.logger.error("Error adding companion %s to device: %s", contact_name, e)
await self._manage_contact_capacity(contact_name, after_add=True)
else:
self.logger.warning(
"Unknown auto_manage_contacts value %r — treating as manual for %s",
auto_manage_setting,
contact_name,
)
await self.bot.repeater_manager.check_and_auto_purge()
if not known_contact:
self.bot.repeater_manager.log_purging_action(
"new_contact_discovered",
f"New contact discovered: {contact_name} (key: {public_key[:16]}...)",
)
return
except Exception as e:
self.logger.error(f"Error handling new contact event: {e}")
import traceback
self.logger.error(traceback.format_exc())