Add comprehensive tests for meshcore-related functionality

- Introduced `test_meshcore.py` with extensive unit tests for:
  - RawSerialProtocol and MeshcoreCompanionProtocol
  - MessageValidator
  - MeshcoreHandler and MeshcoreAsyncHandler
- Enhanced `conftest.py` to include new configuration option `SERIAL_AUTO_SWITCH`.
- Verified encoding and decoding processes for various message types.
- Ensured proper handling of edge cases and error conditions.
This commit is contained in:
sh4un
2026-02-07 14:24:45 -05:00
parent 86aefd1b3e
commit f64b3ae9ec
7 changed files with 1197 additions and 58 deletions

View File

@@ -31,6 +31,14 @@ class AsyncBridge:
debug=self.config.log_level == "DEBUG",
)
try:
# Subscribe to incoming message events before connect
from meshcore import EventType
self.meshcore_handler.subscribe(
EventType.CONTACT_MSG_RECV, self.handle_incoming_message
)
self.meshcore_handler.subscribe(
EventType.CHANNEL_MSG_RECV, self.handle_incoming_message
)
await self.meshcore_handler.run()
except Exception as e:
self.logger.error(f"Unhandled exception in meshcore handler: {e}", exc_info=True)
@@ -86,6 +94,3 @@ class AsyncBridge:
# Add additional async message processing here if needed
except Exception as e:
self.logger.error(f"Error in handle_mqtt_message: {e}", exc_info=True)
def handle_mqtt_message(self, data):
self.logger.info(f"Received MQTT message: {data}")

View File

@@ -55,6 +55,9 @@ class BridgeConfig(NamedTuple):
companion_contacts_poll_s: Optional[int] = 0
companion_debug: Optional[bool] = False
# Serial Auto-Switch (Optional)
serial_auto_switch: Optional[bool] = True
CONFIG_FILE = "config.ini"
@@ -86,6 +89,7 @@ DEFAULT_CONFIG = {
"COMPANION_HANDSHAKE_ENABLED": "True",
"COMPANION_CONTACTS_POLL_S": "0",
"COMPANION_DEBUG": "False",
"SERIAL_AUTO_SWITCH": "True",
}
VALID_LOG_LEVELS = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}
@@ -289,6 +293,9 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
companion_debug = cfg_section.getboolean(
"COMPANION_DEBUG", fallback=False
)
serial_auto_switch = cfg_section.getboolean(
"SERIAL_AUTO_SWITCH", fallback=True
)
bridge_config = BridgeConfig(
meshtastic_port=meshtastic_port,
@@ -320,6 +327,7 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
companion_handshake_enabled=companion_handshake_enabled,
companion_contacts_poll_s=companion_contacts_poll_s,
companion_debug=companion_debug,
serial_auto_switch=serial_auto_switch,
)
logger.debug("Configuration loaded: %s", bridge_config)
return bridge_config

View File

@@ -19,6 +19,7 @@ class MeshcoreAsyncHandler:
self.debug = debug
self.meshcore: Optional[MeshCore] = None
self._event_handlers: Dict[EventType, Callable] = {}
self._pending_subscriptions: list = []
self._connected = asyncio.Event()
self._disconnect_requested = False
@@ -27,6 +28,10 @@ class MeshcoreAsyncHandler:
self.meshcore = await MeshCore.create_serial(self.serial_port, self.baud, debug=self.debug)
self.meshcore.subscribe(EventType.CONNECTED, self._on_connected)
self.meshcore.subscribe(EventType.DISCONNECTED, self._on_disconnected)
# Apply any subscriptions that were registered before connect
for event_type, handler in self._pending_subscriptions:
self.subscribe(event_type, handler)
self._pending_subscriptions.clear()
self._connected.set()
self.logger.info("Meshcore device connected.")
@@ -52,7 +57,10 @@ class MeshcoreAsyncHandler:
def subscribe(self, event_type: EventType, handler: Callable):
if not self.meshcore:
raise RuntimeError("Meshcore not connected.")
# Queue the subscription for when connect() is called
self._pending_subscriptions.append((event_type, handler))
self.logger.info(f"Queued subscription for event: {event_type} (not yet connected)")
return
# Wrap handler for centralized error logging
def safe_handler(event):
try:

View File

@@ -66,10 +66,6 @@ class MeshcoreHandler:
)
self._contacts_poll_thread.start()
RECONNECT_DELAY_S = 10
AUTO_DETECT_FAILURE_THRESHOLD = 5
def __init__(
self,
config: BridgeConfig,
@@ -119,7 +115,7 @@ class MeshcoreHandler:
)
self._failure_count = 0
self._auto_switched = False
self._auto_switch_enabled = getattr(config, "serial_auto_switch", True)
self._auto_switch_enabled = config.serial_auto_switch if config.serial_auto_switch is not None else True
self._protocols_tried = set([self._protocol_name])
try:
self.protocol_handler: MeshcoreProtocolHandler = (
@@ -330,6 +326,7 @@ class MeshcoreHandler:
# --- Read and Process Data ---
try:
raw_data: Optional[bytes] = None
decoded_msg: Optional[Dict[str, Any]] = None
with self._lock:
if self.serial_port and self.serial_port.is_open:
# Delegate reading to protocol handler
@@ -344,12 +341,14 @@ class MeshcoreHandler:
self.health_monitor.update_component(
"external", HealthStatus.HEALTHY, "Serial RX received"
)
decoded_msg: Optional[Dict[str, Any]] = (
decoded_msg = (
self.protocol_handler.decode(raw_data)
)
self.logger.debug(f"Decoded serial message: {decoded_msg}")
if decoded_msg:
# Reset failure count on successful decode
self._failure_count = 0
if decoded_msg.get("internal_only"):
self.logger.info(
"Companion event: %s",
@@ -445,6 +444,13 @@ class MeshcoreHandler:
# No data available - sleep briefly to avoid CPU spin
time.sleep(0.1)
# Track decode failures for protocol auto-switching
if raw_data and not decoded_msg:
self._failure_count += 1
if self._failure_count >= self.AUTO_DETECT_FAILURE_THRESHOLD:
self._switch_protocol()
self._failure_count = 0
except serial.SerialException as e:
self.logger.error(
"Serial error in receiver loop (%s): %s",
@@ -479,6 +485,7 @@ class MeshcoreHandler:
timeout=1
)
if not item:
self.to_serial_queue.task_done()
continue
encoded_message: Optional[bytes] = None
if self._protocol_name == "companion_radio":

View File

@@ -246,9 +246,10 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
base_code = code & 0x7F if code & 0x80 else code
# Text message responses (also handle PUSH variants with high bit set)
if base_code in (7, 16): # RESP_CODE_CONTACT_MSG_RECV / *_V3
# Prefer V3 layout when size permits
if len(raw_data) >= 1 + 1 + 2 + 6 + 1 + 1 + 4:
# V3 contact message (code 16 / 0x10)
if base_code == 16: # RESP_CODE_CONTACT_MSG_RECV_V3
if len(raw_data) < 1 + 1 + 2 + 6 + 1 + 1 + 4:
return None
snr = raw_data[1]
pubkey_prefix = raw_data[4:10]
path_len = raw_data[10]
@@ -270,6 +271,9 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
"sender_timestamp": sender_ts,
"protocol": "companion_radio",
}
# Legacy contact message (code 7)
if base_code == 7: # RESP_CODE_CONTACT_MSG_RECV
if len(raw_data) < 1 + 6 + 1 + 1 + 4:
return None
pubkey_prefix = raw_data[1:7]
@@ -290,12 +294,12 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
"protocol": "companion_radio",
}
if base_code in (8, 17): # RESP_CODE_CHANNEL_MSG_RECV / *_V3
# Prefer V3 layout when size permits and channel index looks valid
if len(raw_data) >= 1 + 1 + 2 + 1 + 1 + 1 + 4:
channel_idx = raw_data[4]
if channel_idx <= 7:
# V3 channel message (code 17 / 0x11)
if base_code == 17: # RESP_CODE_CHANNEL_MSG_RECV_V3
if len(raw_data) < 1 + 1 + 2 + 1 + 1 + 1 + 4:
return None
snr = raw_data[1]
channel_idx = raw_data[4]
path_len = raw_data[5]
txt_type = raw_data[6]
sender_ts = int.from_bytes(
@@ -314,6 +318,9 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
"sender_timestamp": sender_ts,
"protocol": "companion_radio",
}
# Legacy channel message (code 8)
if base_code == 8: # RESP_CODE_CHANNEL_MSG_RECV
if len(raw_data) < 1 + 1 + 1 + 1 + 4:
return None
channel_idx = raw_data[1]

View File

@@ -15,6 +15,7 @@ def temp_config_file(tmp_path):
"SERIAL_PROTOCOL": "json_newline",
"MESSAGE_QUEUE_SIZE": "50",
"LOG_LEVEL": "DEBUG",
"SERIAL_AUTO_SWITCH": "True",
}
with open(config_path, "w") as f:
parser.write(f)

1103
tests/test_meshcore.py Normal file

File diff suppressed because it is too large Load Diff