Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
8a97af4
Enhance core functionality with new features and improvements
agessaman Feb 14, 2026
fdd7563
Enhance CompanionBridge and Protocol Handling
agessaman Feb 15, 2026
80544b6
Enhance CompanionRadio with new path discovery and binary request fea…
agessaman Feb 15, 2026
aa7e5ff
Add CompanionRadio path discovery and binary request handling
agessaman Feb 15, 2026
5c4e7ab
Refactor logging and enhance message handling in CompanionBase and Pa…
agessaman Feb 15, 2026
13cd3c9
Add deduplication for direct and channel messages in CompanionBase
agessaman Feb 15, 2026
02f187c
Add Companion Guide and refactor companion modules
agessaman Feb 15, 2026
5703d6b
Implement flood scope functionality in Companion modules
agessaman Feb 16, 2026
151da9c
Refactor code for improved readability and consistency with pyMC_core…
agessaman Feb 16, 2026
80c5c69
Refactor Companion modules for improved messaging and handler management
agessaman Feb 18, 2026
2ecf74c
feat: add CompanionFrameServer and frame protocol constants
agessaman Feb 18, 2026
77866ee
feat(companion): add missing command handlers and prefs persistence h…
agessaman Feb 18, 2026
31460a8
feat(companion): expand documentation on new class structure
agessaman Feb 19, 2026
5e1b5ef
feat(group_text): enhance channel hash handling and decryption logic
agessaman Feb 19, 2026
07a0334
docs(companion): clarify telemetry request frame format
agessaman Feb 19, 2026
7a18213
Merge remote-tracking branch 'rightup/feat/newRadios' into dev-compan…
agessaman Feb 20, 2026
a4ec4d0
feat(companion): add owner info request handling and parsing, improve…
agessaman Feb 22, 2026
5f299f9
Merge branch 'dev-companion-v2' of https://github.com/agessaman/pyMC_…
agessaman Feb 22, 2026
5b39551
feat(companion): add firmware version level handling in login response
agessaman Feb 22, 2026
452de65
fix(companion): prevent outgoing messages from being pushed as incoming
agessaman Feb 22, 2026
beb45b1
fix(companion): fix multi-hop stats, binary request tag matching, and…
agessaman Feb 24, 2026
98d9e9b
Persist contact changes from adverts and PATH updates to SQLite
agessaman Feb 24, 2026
c6734c1
feat(companion): implement auto-add contact type handling and contact…
agessaman Feb 24, 2026
dd4c681
refactor(companion): enhance contact persistence and connection manag…
agessaman Feb 25, 2026
d702b0c
refactor(companion): improve asynchronous handling of stats and messa…
agessaman Feb 25, 2026
5fb8268
feat(companion): enhance connection management and client timeout han…
agessaman Feb 26, 2026
75ea530
fix(companion): extend client idle timeout and drain writer after eac…
agessaman Feb 26, 2026
8c94631
feat(companion): add SNR and RSSI fields to message handling
agessaman Feb 26, 2026
090dd4f
refactor(companion): streamline advert frame building and contact han…
agessaman Feb 28, 2026
c18b71a
feat(companion): improve CompanionRadio advert pipeline to use Contac…
agessaman Feb 28, 2026
0b6e7e3
feat(companion): enhance text message sending and advert handling - T…
agessaman Feb 28, 2026
f043f16
feat(companion): implement pending ACK CRC management for send_confir…
agessaman Feb 28, 2026
7e376dc
fix(companion): support decimal and hex formats for companion hash
agessaman Feb 28, 2026
6bab637
fix(companion): add SNR and RSSI support in message handling
agessaman Feb 28, 2026
ccaf639
fix(companion): improve client disconnection logging and error handli…
agessaman Feb 28, 2026
0f531bd
fix(companion): update frame size limits and deduplication handling
agessaman Feb 28, 2026
209a8b5
feat(companion): add contact_path_updated callback to ProtocolRespons…
agessaman Mar 1, 2026
fa86810
feat(companion): Add callbacks to CompanionRadio.
agessaman Mar 1, 2026
cd3ccf1
feat(companion): implement raw custom data handling in Companion fram…
agessaman Mar 1, 2026
14d026e
feat(companion): add private key export/import commands to Companion …
agessaman Mar 1, 2026
2a4c4e5
feat(crypto): add method to derive Ed25519 public key from MeshCore p…
agessaman Mar 1, 2026
2e3c2d9
feat(companion): refactor push methods for async handling and backpre…
agessaman Mar 2, 2026
fda7506
fix: guard PATH updates for non-contacts and harden TCP drain
agessaman Mar 2, 2026
0320990
refactor(companion): transition push methods to enqueue frames for im…
agessaman Mar 3, 2026
1e8149e
feat(companion): synchronize node name across handlers for echo detec…
agessaman Mar 3, 2026
df073cb
feat: add radio config instance attributes to KissModemWrapper
agessaman Mar 3, 2026
97c7608
Revert "feat: add radio config instance attributes to KissModemWrapper"
agessaman Mar 3, 2026
d12a68d
feat: add radio config instance attributes to KissModemWrapper
agessaman Mar 3, 2026
4028071
fix: make bridge radio settings read-only from host
agessaman Mar 3, 2026
b56b2f8
Merge branch 'dev-companion-v2' of https://github.com/agessaman/pyMC_…
agessaman Mar 3, 2026
201cb8e
fix: update frequency validation and conversion in CompanionFrameServer
agessaman Mar 4, 2026
a87948c
feat(companion): implement multi-byte path hash encoding and management
agessaman Mar 5, 2026
a851967
Merge pull request #2 from agessaman/dev-companion-v2-multibyte-paths
agessaman Mar 6, 2026
8f4d202
Merge remote-tracking branch 'upstream/feat/newRadios' into dev-compa…
agessaman Mar 6, 2026
1dd0fbb
feat(companion): enhance binary parsing and node name synchronization
agessaman Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,042 changes: 1,042 additions & 0 deletions docs/docs/companion.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ markdown_extensions:
nav:
- Home: index.md
- Node Usage Guide: node.md
- Companion Guide: companion.md
- API Reference:
- Core: api/core.md
- Protocol: api/protocol.md
Expand Down
4 changes: 1 addition & 3 deletions examples/discover_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,7 @@ def main():
if args.radio_type == "kiss-tnc":
print(f"Serial port: {args.serial_port}")

asyncio.run(
discover_nodes(args.radio_type, args.serial_port, args.timeout, args.filter)
)
asyncio.run(discover_nodes(args.radio_type, args.serial_port, args.timeout, args.filter))


if __name__ == "__main__":
Expand Down
12 changes: 6 additions & 6 deletions scripts/test_modem_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
- Encryption/decryption
"""

import sys
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))

from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper
from pymc_core.protocol.identity import Identity, LocalIdentity
from pymc_core.protocol.crypto import CryptoUtils
from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper # noqa: E402
from pymc_core.protocol.crypto import CryptoUtils # noqa: E402
from pymc_core.protocol.identity import Identity, LocalIdentity # noqa: E402


def test_modem_crypto(port: str = "/dev/cu.usbmodem1101"):
Expand Down Expand Up @@ -149,7 +149,7 @@ def test_modem_crypto(port: str = "/dev/cu.usbmodem1101"):
modem_decrypted = modem.decrypt_data(key, mac, ciphertext)
if modem_decrypted:
# Trim padding (modem pads to block size)
modem_decrypted = modem_decrypted[:len(plaintext)]
modem_decrypted = modem_decrypted[: len(plaintext)]
print(f"Modem decrypted: {modem_decrypted}")

if modem_decrypted == plaintext:
Expand Down Expand Up @@ -178,7 +178,7 @@ def test_modem_crypto(port: str = "/dev/cu.usbmodem1101"):
# Decrypt with modem
modem_decrypted2 = modem.decrypt_data(key, python_mac, python_ciphertext)
if modem_decrypted2:
modem_decrypted2 = modem_decrypted2[:len(plaintext)]
modem_decrypted2 = modem_decrypted2[: len(plaintext)]
print(f"Modem decrypted Python ciphertext: {modem_decrypted2}")

if modem_decrypted2 == plaintext:
Expand Down
12 changes: 12 additions & 0 deletions src/pymc_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,17 @@
"__version__",
]

# Conditional import for CompanionRadio
try:
from .companion.companion_radio import CompanionRadio

_COMPANION_AVAILABLE = True
except ImportError:
_COMPANION_AVAILABLE = False
CompanionRadio = None

if _COMPANION_AVAILABLE:
__all__.append("CompanionRadio")


# End of mesh package exports
103 changes: 103 additions & 0 deletions src/pymc_core/companion/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
MeshCore Companion Radio - Python-native implementation.

Provides contact management, messaging with offline queue, advertisement
broadcasting, channel management, path tracking, signing, telemetry,
statistics, and device configuration on top of MeshNode.
"""

from .channel_store import ChannelStore
from .companion_bridge import CompanionBridge
from .companion_radio import CompanionRadio
from .constants import (
ADV_TYPE_CHAT,
ADV_TYPE_REPEATER,
ADV_TYPE_ROOM,
ADV_TYPE_SENSOR,
ADVERT_LOC_NONE,
ADVERT_LOC_SHARE,
AUTOADD_CHAT,
AUTOADD_OVERWRITE_OLDEST,
AUTOADD_REPEATER,
AUTOADD_ROOM,
AUTOADD_SENSOR,
DEFAULT_MAX_CHANNELS,
DEFAULT_MAX_CONTACTS,
DEFAULT_OFFLINE_QUEUE_SIZE,
MSG_SEND_FAILED,
MSG_SEND_SENT_DIRECT,
MSG_SEND_SENT_FLOOD,
STATS_TYPE_CORE,
STATS_TYPE_PACKETS,
STATS_TYPE_RADIO,
TELEM_MODE_ALLOW_ALL,
TELEM_MODE_ALLOW_FLAGS,
TELEM_MODE_DENY,
TXT_TYPE_CLI_DATA,
TXT_TYPE_PLAIN,
TXT_TYPE_SIGNED_PLAIN,
BinaryReqType,
)
from .contact_store import ContactStore
from .frame_server import CompanionFrameServer
from .message_queue import MessageQueue
from .models import AdvertPath, Channel, Contact, NodePrefs, PacketStats, QueuedMessage, SentResult
from .path_cache import PathCache
from .stats_collector import StatsCollector

__all__ = [
# Main classes
"CompanionRadio",
"CompanionBridge",
"CompanionFrameServer",
# Stores
"ContactStore",
"ChannelStore",
"MessageQueue",
"PathCache",
"StatsCollector",
# Models
"Contact",
"Channel",
"NodePrefs",
"SentResult",
"PacketStats",
"AdvertPath",
"QueuedMessage",
# ADV Types
"ADV_TYPE_CHAT",
"ADV_TYPE_REPEATER",
"ADV_TYPE_ROOM",
"ADV_TYPE_SENSOR",
# Text Types
"TXT_TYPE_PLAIN",
"TXT_TYPE_CLI_DATA",
"TXT_TYPE_SIGNED_PLAIN",
# Telemetry Modes
"TELEM_MODE_DENY",
"TELEM_MODE_ALLOW_FLAGS",
"TELEM_MODE_ALLOW_ALL",
# Location Policy
"ADVERT_LOC_NONE",
"ADVERT_LOC_SHARE",
# Auto-Add Config
"AUTOADD_OVERWRITE_OLDEST",
"AUTOADD_CHAT",
"AUTOADD_REPEATER",
"AUTOADD_ROOM",
"AUTOADD_SENSOR",
# Message Send Result
"MSG_SEND_FAILED",
"MSG_SEND_SENT_FLOOD",
"MSG_SEND_SENT_DIRECT",
# Binary request types
"BinaryReqType",
# Stats Types
"STATS_TYPE_CORE",
"STATS_TYPE_RADIO",
"STATS_TYPE_PACKETS",
# Defaults
"DEFAULT_MAX_CONTACTS",
"DEFAULT_MAX_CHANNELS",
"DEFAULT_OFFLINE_QUEUE_SIZE",
]
150 changes: 150 additions & 0 deletions src/pymc_core/companion/binary_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Parse binary response payloads by request type (BinaryReqType)."""

from __future__ import annotations

import logging
from typing import Optional

from .constants import BinaryReqType

logger = logging.getLogger(__name__)


def parse_binary_response(
request_type: int,
data: bytes,
pubkey_prefix: str = "",
context: Optional[dict] = None,
) -> Optional[dict]:
"""Parse response_data by request_type. Returns dict or None."""
if request_type == BinaryReqType.STATUS and len(data) >= 52:
return _parse_status(data, pubkey_prefix=pubkey_prefix or None)
if request_type == BinaryReqType.TELEMETRY and len(data) >= 0:
return _parse_telemetry(data)
if request_type == BinaryReqType.MMA and len(data) >= 4:
return _parse_mma(data[4:]) # skip 4-byte header
if request_type == BinaryReqType.ACL:
return _parse_acl(data)
if request_type == BinaryReqType.NEIGHBOURS:
return _parse_neighbours(data, context or {})
if request_type == BinaryReqType.OWNER_INFO and len(data) >= 4:
return _parse_owner_info(data)
return {"raw_hex": data.hex(), "request_type": request_type}


def _parse_status(data: bytes, pubkey_prefix: Optional[str] = None, offset: int = 0) -> dict:
"""Parse status response (52 bytes)."""
res = {}
if pubkey_prefix is None and len(data) >= 8:
res["pubkey_pre"] = data[2:8].hex()
offset = 8
else:
res["pubkey_pre"] = pubkey_prefix or ""
res["bat"] = int.from_bytes(data[offset : offset + 2], byteorder="little")
res["tx_queue_len"] = int.from_bytes(data[offset + 2 : offset + 4], byteorder="little")
res["noise_floor"] = int.from_bytes(
data[offset + 4 : offset + 6], byteorder="little", signed=True
)
res["last_rssi"] = int.from_bytes(
data[offset + 6 : offset + 8], byteorder="little", signed=True
)
res["nb_recv"] = int.from_bytes(data[offset + 8 : offset + 12], byteorder="little")
res["nb_sent"] = int.from_bytes(data[offset + 12 : offset + 16], byteorder="little")
res["airtime"] = int.from_bytes(data[offset + 16 : offset + 20], byteorder="little")
res["uptime"] = int.from_bytes(data[offset + 20 : offset + 24], byteorder="little")
res["sent_flood"] = int.from_bytes(data[offset + 24 : offset + 28], byteorder="little")
res["sent_direct"] = int.from_bytes(data[offset + 28 : offset + 32], byteorder="little")
res["recv_flood"] = int.from_bytes(data[offset + 32 : offset + 36], byteorder="little")
res["recv_direct"] = int.from_bytes(data[offset + 36 : offset + 40], byteorder="little")
res["full_evts"] = int.from_bytes(data[offset + 40 : offset + 42], byteorder="little")
res["last_snr"] = (
int.from_bytes(data[offset + 42 : offset + 44], byteorder="little", signed=True) / 4
)
res["direct_dups"] = int.from_bytes(data[offset + 44 : offset + 46], byteorder="little")
res["flood_dups"] = int.from_bytes(data[offset + 46 : offset + 48], byteorder="little")
res["rx_airtime"] = int.from_bytes(data[offset + 48 : offset + 52], byteorder="little")
return res


def _parse_telemetry(data: bytes) -> dict:
"""Telemetry: Cayenne LPP or raw. Dict has raw_hex; optional LPP if cayennelpp available."""
out: dict = {"raw_hex": data.hex()}
try:
from cayennelpp import LppFrame

frame = LppFrame.from_bytes(data)
out["lpp"] = [
{"channel": d.channel, "type": d.type_id, "value": d.data} for d in frame.data
]
except Exception:
logger.debug("Optional LPP parse failed for telemetry", exc_info=True)
return out


def _parse_mma(data: bytes) -> dict:
"""MMA: LPP min/max/avg or raw."""
out: dict = {"raw_hex": data.hex()}
try:
from cayennelpp import LppFrame

frame = LppFrame.from_bytes(data)
out["mma"] = [{"channel": d.channel, "type": d.type_id, "data": d.data} for d in frame.data]
except Exception:
logger.debug("Optional LPP parse failed for MMA", exc_info=True)
return out


def _parse_owner_info(data: bytes) -> dict:
"""Parse GET_OWNER_INFO response: tag(4) + 'version\\nname\\nowner' (variable)."""
try:
text = data[4:].decode("utf-8", errors="replace").strip()
parts = text.split("\n", 2)
return {
"tag": int.from_bytes(data[:4], "little"),
"version": parts[0] if len(parts) > 0 else "",
"node_name": parts[1] if len(parts) > 1 else "",
"owner_info": parts[2] if len(parts) > 2 else "",
"raw_text": text,
}
except Exception:
logger.debug("Owner info parse failed, returning fallback", exc_info=True)
return {"raw_hex": data.hex(), "request_type": BinaryReqType.OWNER_INFO}


def _parse_acl(buf: bytes) -> dict:
"""ACL: 7-byte entries (key 6 + perm 1)."""
res = []
i = 0
while i + 7 <= len(buf):
key = buf[i : i + 6].hex()
perm = buf[i + 6]
if key != "000000000000":
res.append({"key": key, "perm": perm})
i += 7
return {"acl": res}


def _parse_neighbours(data: bytes, context: dict) -> dict:
"""Neighbours: count(2) + results_count(2) + entries (pubkey_prefix + secs_ago(4) + snr(1))."""
if len(data) < 4:
return {"raw_hex": data.hex()}
pk_plen = context.get("pubkey_prefix_length", 6)
neighbours_count = int.from_bytes(data[0:2], "little", signed=True)
results_count = int.from_bytes(data[2:4], "little", signed=True)
neighbours_list = []
i = 4
for _ in range(results_count):
if i + pk_plen + 4 + 1 > len(data):
break
pubkey = data[i : i + pk_plen].hex()
i += pk_plen
secs_ago = int.from_bytes(data[i : i + 4], "little", signed=True)
i += 4
snr = int.from_bytes(data[i : i + 1], "little", signed=True) / 4
i += 1
neighbours_list.append({"pubkey": pubkey, "secs_ago": secs_ago, "snr": snr})
return {
"neighbours_count": neighbours_count,
"results_count": results_count,
"neighbours": neighbours_list,
}
Loading