Add thread safety to MeshConnector node caches and StatusData

- 4a: Add threading.Lock to MeshConnector protecting _node_names and
  _node_positions dicts that are read/written from Meshtastic's pubsub
  thread callbacks (_on_receive, _on_node_update, _cache_node_info)
  and read from async code (get_node_position, get_node_name)
- 4b: Add threading.Lock to StatusData protecting counters and activity
  list that are written from the async event loop and read from the
  HTTP server thread in to_dict()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-02-23 20:13:12 +00:00
commit dca03500ec
2 changed files with 75 additions and 60 deletions

View file

@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable, Optional from typing import Callable, Optional
@ -46,6 +47,7 @@ class MeshConnector:
self._node_names: dict[str, str] = {} self._node_names: dict[str, str] = {}
self._connected = False self._connected = False
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._lock = threading.Lock()
@property @property
def connected(self) -> bool: def connected(self) -> bool:
@ -127,34 +129,36 @@ class MeshConnector:
if not self._interface: if not self._interface:
return return
for node_id, node in self._interface.nodes.items(): with self._lock:
# Cache name for node_id, node in self._interface.nodes.items():
if user := node.get("user"): # Cache name
name = user.get("shortName") or user.get("longName") or node_id if user := node.get("user"):
self._node_names[node_id] = name name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Cache position # Cache position
if position := node.get("position"): if position := node.get("position"):
lat = position.get("latitude") lat = position.get("latitude")
lon = position.get("longitude") lon = position.get("longitude")
if lat is not None and lon is not None: if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon) self._node_positions[node_id] = (lat, lon)
def _on_node_update(self, node, interface) -> None: def _on_node_update(self, node, interface) -> None:
"""Handle node info updates.""" """Handle node info updates."""
node_id = f"!{node['num']:08x}" node_id = f"!{node['num']:08x}"
# Update name cache with self._lock:
if user := node.get("user"): # Update name cache
name = user.get("shortName") or user.get("longName") or node_id if user := node.get("user"):
self._node_names[node_id] = name name = user.get("shortName") or user.get("longName") or node_id
self._node_names[node_id] = name
# Update position cache # Update position cache
if position := node.get("position"): if position := node.get("position"):
lat = position.get("latitude") lat = position.get("latitude")
lon = position.get("longitude") lon = position.get("longitude")
if lat is not None and lon is not None: if lat is not None and lon is not None:
self._node_positions[node_id] = (lat, lon) self._node_positions[node_id] = (lat, lon)
def _on_receive(self, packet, interface) -> None: def _on_receive(self, packet, interface) -> None:
"""Handle incoming text message.""" """Handle incoming text message."""
@ -175,8 +179,11 @@ class MeshConnector:
# Determine if DM (sent directly to us, not broadcast) # Determine if DM (sent directly to us, not broadcast)
is_dm = to_num == self._my_node_id is_dm = to_num == self._my_node_id
# Get sender name with self._lock:
sender_name = self._node_names.get(sender_num, sender_num) # Get sender name
sender_name = self._node_names.get(sender_num, sender_num)
# Get position if available
position = self._node_positions.get(sender_num)
# Create message object # Create message object
msg = MeshMessage( msg = MeshMessage(
@ -189,8 +196,8 @@ class MeshConnector:
) )
# Attach position if available # Attach position if available
if sender_num in self._node_positions: if position:
msg._position = self._node_positions[sender_num] msg._position = position
# Schedule callback on event loop # Schedule callback on event loop
self._loop.call_soon_threadsafe( self._loop.call_soon_threadsafe(
@ -258,7 +265,8 @@ class MeshConnector:
Returns: Returns:
Tuple of (latitude, longitude) or None if not available Tuple of (latitude, longitude) or None if not available
""" """
return self._node_positions.get(node_id) with self._lock:
return self._node_positions.get(node_id)
def get_node_name(self, node_id: str) -> str: def get_node_name(self, node_id: str) -> str:
"""Get cached name for a node. """Get cached name for a node.
@ -269,4 +277,5 @@ class MeshConnector:
Returns: Returns:
Node name or the node ID if name not available Node name or the node ID if name not available
""" """
return self._node_names.get(node_id, node_id) with self._lock:
return self._node_names.get(node_id, node_id)

View file

@ -3,6 +3,7 @@
import asyncio import asyncio
import json import json
import logging import logging
import threading
import time import time
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
@ -18,6 +19,7 @@ class StatusData:
"""Container for status information.""" """Container for status information."""
def __init__(self): def __init__(self):
self._lock = threading.Lock()
self.start_time = time.time() self.start_time = time.time()
self.message_count = 0 self.message_count = 0
self.response_count = 0 self.response_count = 0
@ -29,31 +31,34 @@ class StatusData:
def record_message(self, sender_id: str, sender_name: str): def record_message(self, sender_id: str, sender_name: str):
"""Record an incoming message.""" """Record an incoming message."""
self.message_count += 1 with self._lock:
self.last_message_time = time.time() self.message_count += 1
self.connected_nodes.add(sender_id) self.last_message_time = time.time()
self.connected_nodes.add(sender_id)
self.recent_activity.append({ self.recent_activity.append({
"type": "message", "type": "message",
"time": datetime.now().isoformat(), "time": datetime.now().isoformat(),
"sender": sender_name, "sender": sender_name,
}) })
# Keep only last 20 activities # Keep only last 20 activities
self.recent_activity = self.recent_activity[-20:] self.recent_activity = self.recent_activity[-20:]
def record_response(self): def record_response(self):
"""Record an outgoing response.""" """Record an outgoing response."""
self.response_count += 1 with self._lock:
self.response_count += 1
def record_error(self, error: str): def record_error(self, error: str):
"""Record an error.""" """Record an error."""
self.error_count += 1 with self._lock:
self.recent_activity.append({ self.error_count += 1
"type": "error", self.recent_activity.append({
"time": datetime.now().isoformat(), "type": "error",
"error": error[:100], "time": datetime.now().isoformat(),
}) "error": error[:100],
self.recent_activity = self.recent_activity[-20:] })
self.recent_activity = self.recent_activity[-20:]
def get_uptime(self) -> str: def get_uptime(self) -> str:
"""Get formatted uptime string.""" """Get formatted uptime string."""
@ -75,22 +80,23 @@ class StatusData:
def to_dict(self, include_activity: bool = False) -> dict: def to_dict(self, include_activity: bool = False) -> dict:
"""Convert to dictionary for JSON response.""" """Convert to dictionary for JSON response."""
data = { with self._lock:
"status": "online", data = {
"uptime": self.get_uptime(), "status": "online",
"uptime_seconds": int(time.time() - self.start_time), "uptime": self.get_uptime(),
"messages_received": self.message_count, "uptime_seconds": int(time.time() - self.start_time),
"responses_sent": self.response_count, "messages_received": self.message_count,
"errors": self.error_count, "responses_sent": self.response_count,
"connected_nodes": len(self.connected_nodes), "errors": self.error_count,
"using_fallback": self.using_fallback, "connected_nodes": len(self.connected_nodes),
} "using_fallback": self.using_fallback,
}
if self.last_message_time: if self.last_message_time:
data["last_message_ago"] = int(time.time() - self.last_message_time) data["last_message_ago"] = int(time.time() - self.last_message_time)
if include_activity: if include_activity:
data["recent_activity"] = self.recent_activity data["recent_activity"] = list(self.recent_activity)
return data return data