feat: implement message polling and handling for companion protocol

This commit is contained in:
sh4un committed 2026-02-09 08:38:33 -05:00
1 parent 920b433f97
commit f23069df17
3 files changed
+99 -26

No files matched your search

+35 -2
View File
@@ -113,6 +113,7 @@ class MeshcoreHandler:
self._companion_debug = bool(
getattr(config, "companion_debug", False)
)
self._companion_msg_polling = False
self._failure_count = 0
self._auto_switched = False
self._auto_switch_enabled = config.serial_auto_switch if config.serial_auto_switch is not None else True
@@ -233,6 +234,9 @@ class MeshcoreHandler:
app_name = b"AMMB"
payload = bytes([1, 3]) + (b"\x00" * 6) + app_name
self._send_companion_command(payload, "CMD_APP_START")
# Initial poll for any pending messages
self._companion_msg_polling = True
self._send_companion_command(bytes([10]), "CMD_SYNC_NEXT_MESSAGE")
except Exception as e:
self.logger.warning("Failed to send companion handshake: %s", e)
@@ -250,6 +254,21 @@ class MeshcoreHandler:
except Exception as e:
self.logger.warning("Error sending companion command %s: %s", label, e)
def _send_sync_next_message(self):
"""Send CMD_SYNC_NEXT_MESSAGE to poll queued messages from MeshCore."""
try:
payload = bytes([10]) # CMD_SYNC_NEXT_MESSAGE
encoded = self.protocol_handler.encode({"payload": payload})
if not encoded:
return
with self._lock:
if self.serial_port and self.serial_port.is_open:
self.serial_port.write(encoded)
self.serial_port.flush()
self.logger.debug("Sent CMD_SYNC_NEXT_MESSAGE")
except Exception as e:
self.logger.warning("Failed to send CMD_SYNC_NEXT_MESSAGE: %s", e)
def _contacts_poll_loop(self) -> None:
"""Periodically request contacts from MeshCore to surface adverts."""
self.logger.info(
@@ -280,6 +299,7 @@ class MeshcoreHandler:
self.logger.info("Serial handler stopped.")
def _close_serial(self):
self._companion_msg_polling = False
with self._lock:
if self.serial_port and self.serial_port.is_open:
port_name = self.config.serial_port
@@ -350,10 +370,17 @@ class MeshcoreHandler:
# Reset failure count on successful decode
self._failure_count = 0
if decoded_msg.get("internal_only"):
kind = decoded_msg.get("companion_kind")
self.logger.info(
"Companion event: %s",
decoded_msg.get("companion_kind"),
"Companion event: %s", kind
)
# Handle message polling
if kind == "msg_waiting":
if not self._companion_msg_polling:
self._companion_msg_polling = True
self._send_sync_next_message()
elif kind == "no_more_messages":
self._companion_msg_polling = False
continue
if self._protocol_name == "companion_radio" and "payload" not in decoded_msg:
self.logger.debug("Skipping non-message companion frame.")
@@ -430,6 +457,12 @@ class MeshcoreHandler:
self.logger.info(
f"Queued message for Meshtastic {dest_meshtastic_id}"
)
# Continue polling if active
if (
self._protocol_name == "companion_radio"
and self._companion_msg_polling
):
self._send_sync_next_message()
except Full:
self.logger.warning(
"Meshtastic queue full; dropping message."
+33 -15
View File
@@ -237,17 +237,19 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]:
"""Decode companion payload bytes into a dict.
Only forwards text-message frames; other frames are ignored.
Handles RESP codes (from CMD_SYNC_NEXT_MESSAGE) and PUSH codes
(unsolicited events from the radio). PUSH codes (>=0x80) have
their own meanings and must NOT be mapped via base_code masking.
"""
if not raw_data:
return None
code = raw_data[0]
base_code = code & 0x7F if code & 0x80 else code
# Text message responses (also handle PUSH variants with high bit set)
# --- RESP codes (responses to CMD_SYNC_NEXT_MESSAGE) ---
# V3 contact message (code 16 / 0x10)
if base_code == 16: # RESP_CODE_CONTACT_MSG_RECV_V3
if 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]
@@ -273,7 +275,7 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
}
# Legacy contact message (code 7)
if base_code == 7: # RESP_CODE_CONTACT_MSG_RECV
if code == 7: # RESP_CODE_CONTACT_MSG_RECV
if len(raw_data) < 1 + 6 + 1 + 1 + 4:
return None
pubkey_prefix = raw_data[1:7]
@@ -295,7 +297,7 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
}
# V3 channel message (code 17 / 0x11)
if base_code == 17: # RESP_CODE_CHANNEL_MSG_RECV_V3
if 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]
@@ -320,7 +322,7 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
}
# Legacy channel message (code 8)
if base_code == 8: # RESP_CODE_CHANNEL_MSG_RECV
if code == 8: # RESP_CODE_CHANNEL_MSG_RECV
if len(raw_data) < 1 + 1 + 1 + 1 + 4:
return None
channel_idx = raw_data[1]
@@ -371,6 +373,15 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
"protocol": "companion_radio",
}
if code == 10: # RESP_CODE_NO_MORE_MESSAGES
return {
"companion_kind": "no_more_messages",
"internal_only": True,
"protocol": "companion_radio",
}
# --- PUSH codes (unsolicited events from the radio) ---
if code == 0x82: # PUSH_CODE_SEND_CONFIRMED
if len(raw_data) < 1 + 4 + 4:
return None
@@ -384,15 +395,22 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
"protocol": "companion_radio",
}
if code == 0x83: # PUSH_CODE_MSG_WAITING
return {
"companion_kind": "msg_waiting",
"internal_only": True,
"protocol": "companion_radio",
}
if code == 0x80: # PUSH_CODE_ADVERT
if len(raw_data) < 1 + 32:
return None
pubkey = raw_data[1:33]
self.logger.info("MeshCore advert from: %s", pubkey[:4].hex())
return {
"destination_meshtastic_id": "^all",
"payload": f"MC_ADVERT:{pubkey.hex()}",
"channel_index": 0,
"companion_kind": "advert",
"pubkey": pubkey.hex(),
"internal_only": True,
"protocol": "companion_radio",
}
@@ -400,16 +418,16 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
if len(raw_data) < 1 + 32:
return None
pubkey = raw_data[1:33]
self.logger.info("MeshCore new advert from: %s", pubkey[:4].hex())
return {
"destination_meshtastic_id": "^all",
"payload": f"MC_NEW_ADVERT:{pubkey.hex()}",
"channel_index": 0,
"companion_kind": "new_advert",
"pubkey": pubkey.hex(),
"internal_only": True,
"protocol": "companion_radio",
}
# Ignore non-message frames (device info, acks, errors, etc.)
self.logger.debug("Ignoring companion frame code: %s", code)
# Ignore non-message frames (device info, log data, etc.)
self.logger.debug("Ignoring companion frame code: 0x%02x", code)
return None
_serial_protocol_handlers = {
+31 -9
View File
@@ -261,24 +261,21 @@ class TestMeshcoreCompanionProtocol:
assert result["snr"] == 5
def test_decode_push_channel_msg(self, handler):
"""PUSH variant (high bit set): code 0x88 = 8 | 0x80, base_code=8"""
"""0x88 is PUSH_CODE_LOG_RX_DATA (not a channel msg) and should be ignored."""
ts = 4000
ts_bytes = ts.to_bytes(4, "little")
raw = bytes([0x88, 1, 0, 0]) + ts_bytes + b"push channel"
result = handler.decode(raw)
assert result is not None
assert result["payload"] == "push channel"
assert result["channel_index"] == 1
assert result is None # 0x88 is not a channel message
def test_decode_push_contact_msg(self, handler):
"""PUSH variant (high bit set): code 0x87 = 7 | 0x80, base_code=7"""
"""0x87 is PUSH_CODE_STATUS_RESPONSE (not a contact msg) and should be ignored."""
pubkey = b"\x01\x02\x03\x04\x05\x06"
ts = 5000
ts_bytes = ts.to_bytes(4, "little")
raw = bytes([0x87]) + pubkey + bytes([0, 0]) + ts_bytes + b"push contact"
result = handler.decode(raw)
assert result is not None
assert result["payload"] == "push contact"
assert result is None # 0x87 is not a contact message
# --- decode: control frames ---
def test_decode_ok_response(self, handler):
@@ -333,7 +330,9 @@ class TestMeshcoreCompanionProtocol:
result = handler.decode(raw)
assert result is not None
assert result["companion_kind"] == "advert"
assert "MC_ADVERT:" in result["payload"]
assert result["internal_only"] is True
assert result["pubkey"] == pubkey.hex()
assert "payload" not in result
def test_decode_advert_too_short(self, handler):
result = handler.decode(bytes([0x80]) + b"\x00" * 10)
@@ -345,7 +344,9 @@ class TestMeshcoreCompanionProtocol:
result = handler.decode(raw)
assert result is not None
assert result["companion_kind"] == "new_advert"
assert "MC_NEW_ADVERT:" in result["payload"]
assert result["internal_only"] is True
assert result["pubkey"] == pubkey.hex()
assert "payload" not in result
def test_decode_unknown_frame_returns_none(self, handler):
"""Unknown/unhandled frame codes should return None."""
@@ -361,6 +362,27 @@ class TestMeshcoreCompanionProtocol:
result = handler.decode(bytes([8, 0, 1]))
assert result is None
# --- decode: new companion protocol frames ---
def test_decode_msg_waiting(self, handler):
"""PUSH_CODE_MSG_WAITING (0x83) should return internal msg_waiting event."""
result = handler.decode(bytes([0x83]))
assert result is not None
assert result["companion_kind"] == "msg_waiting"
assert result["internal_only"] is True
def test_decode_no_more_messages(self, handler):
"""RESP_CODE_NO_MORE_MESSAGES (10) should return internal no_more_messages event."""
result = handler.decode(bytes([10]))
assert result is not None
assert result["companion_kind"] == "no_more_messages"
assert result["internal_only"] is True
def test_decode_log_rx_data_ignored(self, handler):
"""PUSH_CODE_LOG_RX_DATA (0x88) should be ignored (return None)."""
raw = bytes([0x88]) + b"\x00" * 50
result = handler.decode(raw)
assert result is None
# =========================================================================
# Protocol Tests: Factory