forked from iarv/Akita-Meshtastic-Meshcore-Bridge
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:
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
103
ammb/protocol.py
103
ammb/protocol.py
@@ -246,30 +246,34 @@ 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:
|
||||
snr = raw_data[1]
|
||||
pubkey_prefix = raw_data[4:10]
|
||||
path_len = raw_data[10]
|
||||
txt_type = raw_data[11]
|
||||
sender_ts = int.from_bytes(
|
||||
raw_data[12:16], "little", signed=False
|
||||
)
|
||||
text_bytes = raw_data[16:]
|
||||
text = text_bytes.decode("utf-8", errors="replace")
|
||||
return {
|
||||
"destination_meshtastic_id": "^all",
|
||||
"payload": text,
|
||||
"channel_index": 0,
|
||||
"companion_kind": "contact_msg",
|
||||
"sender_pubkey_prefix": pubkey_prefix.hex(),
|
||||
"path_len": path_len,
|
||||
"txt_type": txt_type,
|
||||
"snr": snr,
|
||||
"sender_timestamp": sender_ts,
|
||||
"protocol": "companion_radio",
|
||||
}
|
||||
# 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]
|
||||
txt_type = raw_data[11]
|
||||
sender_ts = int.from_bytes(
|
||||
raw_data[12:16], "little", signed=False
|
||||
)
|
||||
text_bytes = raw_data[16:]
|
||||
text = text_bytes.decode("utf-8", errors="replace")
|
||||
return {
|
||||
"destination_meshtastic_id": "^all",
|
||||
"payload": text,
|
||||
"channel_index": 0,
|
||||
"companion_kind": "contact_msg",
|
||||
"sender_pubkey_prefix": pubkey_prefix.hex(),
|
||||
"path_len": path_len,
|
||||
"txt_type": txt_type,
|
||||
"snr": snr,
|
||||
"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,30 +294,33 @@ 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:
|
||||
snr = raw_data[1]
|
||||
path_len = raw_data[5]
|
||||
txt_type = raw_data[6]
|
||||
sender_ts = int.from_bytes(
|
||||
raw_data[7:11], "little", signed=False
|
||||
)
|
||||
text_bytes = raw_data[11:]
|
||||
text = text_bytes.decode("utf-8", errors="replace")
|
||||
return {
|
||||
"destination_meshtastic_id": "^all",
|
||||
"payload": text,
|
||||
"channel_index": channel_idx,
|
||||
"companion_kind": "channel_msg",
|
||||
"path_len": path_len,
|
||||
"txt_type": txt_type,
|
||||
"snr": snr,
|
||||
"sender_timestamp": sender_ts,
|
||||
"protocol": "companion_radio",
|
||||
}
|
||||
# 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(
|
||||
raw_data[7:11], "little", signed=False
|
||||
)
|
||||
text_bytes = raw_data[11:]
|
||||
text = text_bytes.decode("utf-8", errors="replace")
|
||||
return {
|
||||
"destination_meshtastic_id": "^all",
|
||||
"payload": text,
|
||||
"channel_index": channel_idx,
|
||||
"companion_kind": "channel_msg",
|
||||
"path_len": path_len,
|
||||
"txt_type": txt_type,
|
||||
"snr": snr,
|
||||
"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]
|
||||
|
||||
@@ -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
1103
tests/test_meshcore.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user