feat: Refactor binary commands and apply BLE fixes

Refactored the BinaryCommandHandler to align with the other command handlers, inheriting from CommandHandlerBase. This resolves an AttributeError and simplifies the command structure. Moved binary_commands.py into the commands module. Applied fixes to the BLE connection handler based on feedback, improving reliability on macOS and ensuring the device address is correctly handled.
This commit is contained in:
Ventz Petkov 2025-08-05 15:31:54 -04:00
parent c19fd166f8
commit 36727f4ea3
22 changed files with 1603 additions and 1206 deletions

View file

@ -21,7 +21,7 @@ license-files = ["LICEN[CS]E*"]
dependencies = [ "bleak", "pyserial-asyncio", "pycayennelpp" ] dependencies = [ "bleak", "pyserial-asyncio", "pycayennelpp" ]
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest", "pytest-asyncio"] dev = ["pytest", "pytest-asyncio", "black", "ruff"]
[project.urls] [project.urls]
Homepage = "https://github.com/fdlamotte/meshcore_py" Homepage = "https://github.com/fdlamotte/meshcore_py"

View file

@ -1,11 +1,23 @@
"""A library for communicating with meshcore devices."""
import logging import logging
from .ble_cx import BLEConnection
from .connection_manager import ConnectionManager
from .events import EventType
from .meshcore import MeshCore
from .serial_cx import SerialConnection
from .tcp_cx import TCPConnection
# Setup default logger # Setup default logger
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
from meshcore.events import EventType __all__ = [
from meshcore.meshcore import MeshCore, logger "BLEConnection",
from meshcore.connection_manager import ConnectionManager "ConnectionManager",
from meshcore.tcp_cx import TCPConnection "EventType",
from meshcore.ble_cx import BLEConnection "MeshCore",
from meshcore.serial_cx import SerialConnection "SerialConnection",
"TCPConnection",
"logger",
]

View file

@ -1,116 +0,0 @@
import asyncio
import logging
from enum import Enum
import json
from .events import Event, EventType
from cayennelpp import LppFrame, LppData
from cayennelpp.lpp_type import LppType
from meshcore.lpp_json_encoder import lpp_json_encoder, my_lpp_types, lpp_format_val
logger = logging.getLogger("meshcore")
class BinaryReqType(Enum):
TELEMETRY = 0x03
MMA = 0x04
ACL = 0x05
def lpp_parse(buf):
"""Parse a given byte string and return as a LppFrame object."""
i = 0
lpp_data_list = []
while i < len(buf) and buf[i] != 0:
lppdata = LppData.from_bytes(buf[i:])
lpp_data_list.append(lppdata)
i = i + len(lppdata)
return json.loads(json.dumps(LppFrame(lpp_data_list), default=lpp_json_encoder))
def lpp_parse_mma (buf):
i = 0
res = []
while i < len(buf) and buf[i] != 0:
chan = buf[i]
i = i + 1
type = buf[i]
lpp_type = LppType.get_lpp_type(type)
size = lpp_type.size
i = i + 1
min = lpp_format_val(lpp_type, lpp_type.decode(buf[i:i+size]))
i = i + size
max = lpp_format_val(lpp_type, lpp_type.decode(buf[i:i+size]))
i = i + size
avg = lpp_format_val(lpp_type, lpp_type.decode(buf[i:i+size]))
i = i + size
res.append({"channel":chan,
"type":my_lpp_types[type][0],
"min":min,
"max":max,
"avg":avg,
})
return res
def parse_acl (buf):
i = 0
res = []
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 = i + 7
return res
class BinaryCommandHandler :
""" Helper functions to handle binary requests through binary commands """
def __init__ (self, c):
self.commands = c
@property
def dispatcher(self):
return self.commands.dispatcher
async def req_binary (self, contact, request, timeout=0) :
res = await self.commands.send_binary_req(contact, request)
logger.debug(res)
if res.type == EventType.ERROR:
logger.error(f"Error while requesting binary data")
return None
else:
exp_tag = res.payload["expected_ack"].hex()
timeout = res.payload["suggested_timeout"]/800 if timeout == 0 else timeout
res2 = await self.dispatcher.wait_for_event(EventType.BINARY_RESPONSE, attribute_filters={"tag": exp_tag}, timeout=timeout)
logger.debug(res2)
if res2 is None :
return None
else:
return res2.payload
async def req_telemetry (self, contact, timeout=0) :
code = BinaryReqType.TELEMETRY.value
req = code.to_bytes(1, 'little', signed=False)
res = await self.req_binary(contact, req, timeout)
if (res is None) :
return None
else:
return lpp_parse(bytes.fromhex(res["data"]))
async def req_mma (self, contact, start, end, timeout=0) :
code = BinaryReqType.MMA.value
req = code.to_bytes(1, 'little', signed=False)\
+ start.to_bytes(4, 'little', signed = False)\
+ end.to_bytes(4, 'little', signed=False)\
+ b"\0\0"
res = await self.req_binary(contact, req, timeout)
if (res is None) :
return None
else:
return lpp_parse_mma(bytes.fromhex(res["data"])[4:])
async def req_acl (self, contact, timeout=0) :
code = BinaryReqType.ACL.value
req = code.to_bytes(1, 'little', signed=False) + b"\0\0"
res = await self.req_binary(contact, req, timeout)
if (res is None) :
return None
else:
return parse_acl(bytes.fromhex(res['data']))

View file

@ -1,22 +1,24 @@
""" """
mccli.py : CLI interface to MeschCore BLE companion app mccli.py : CLI interface to MeschCore BLE companion app
""" """
import asyncio import asyncio
import logging import logging
# Get logger
logger = logging.getLogger("meshcore")
from bleak import BleakClient, BleakScanner from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from bleak.exc import BleakDeviceNotFoundError from bleak.exc import BleakDeviceNotFoundError
# Get logger
logger = logging.getLogger("meshcore")
UART_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" UART_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
class BLEConnection: class BLEConnection:
def __init__(self, address=None, client=None): def __init__(self, address=None, client=None):
""" """
@ -48,7 +50,9 @@ class BLEConnection:
logger.debug("Using pre-configured BleakClient.") logger.debug("Using pre-configured BleakClient.")
# If a client is already provided, ensure its disconnect callback is set # If a client is already provided, ensure its disconnect callback is set
self.client._disconnected_callback = self.handle_disconnect self.client._disconnected_callback = self.handle_disconnect
self.address = self.client.address
else: else:
def match_meshcore_device(_: BLEDevice, adv: AdvertisementData): def match_meshcore_device(_: BLEDevice, adv: AdvertisementData):
"""Filter to match MeshCore devices.""" """Filter to match MeshCore devices."""
if adv.local_name and adv.local_name.startswith("MeshCore"): if adv.local_name and adv.local_name.startswith("MeshCore"):
@ -63,10 +67,14 @@ class BLEConnection:
logger.warning("No MeshCore device found during scan.") logger.warning("No MeshCore device found during scan.")
return None return None
logger.info(f"Found device: {device}") logger.info(f"Found device: {device}")
self.client = BleakClient(device, disconnected_callback=self.handle_disconnect) self.client = BleakClient(
device, disconnected_callback=self.handle_disconnect
)
self.address = self.client.address self.address = self.client.address
else: else:
self.client = BleakClient(self.address, disconnected_callback=self.handle_disconnect) self.client = BleakClient(
self.address, disconnected_callback=self.handle_disconnect
)
try: try:
await self.client.connect() await self.client.connect()
@ -87,8 +95,10 @@ class BLEConnection:
return self.address return self.address
def handle_disconnect(self, client: BleakClient): def handle_disconnect(self, client: BleakClient):
""" Callback to handle disconnection """ """Callback to handle disconnection"""
logger.debug(f"BLE device disconnected: {client.address} (is_connected: {client.is_connected})") logger.debug(
f"BLE device disconnected: {client.address} (is_connected: {client.is_connected})"
)
# Reset the address we found to what user specified # Reset the address we found to what user specified
# this allows to reconnect to the same device # this allows to reconnect to the same device
self.address = self._user_provided_address self.address = self._user_provided_address
@ -100,11 +110,11 @@ class BLEConnection:
"""Set callback to handle disconnections.""" """Set callback to handle disconnections."""
self._disconnect_callback = callback self._disconnect_callback = callback
def set_reader(self, reader) : def set_reader(self, reader):
self.reader = reader self.reader = reader
def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray): def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
if not self.reader is None: if self.reader is not None:
asyncio.create_task(self.reader.handle_rx(data)) asyncio.create_task(self.reader.handle_rx(data))
async def send(self, data): async def send(self, data):
@ -114,7 +124,7 @@ class BLEConnection:
if not self.rx_char: if not self.rx_char:
logger.error("RX characteristic not found") logger.error("RX characteristic not found")
return False return False
await self.client.write_gatt_char(self.rx_char, bytes(data), response=False) await self.client.write_gatt_char(self.rx_char, bytes(data), response=True)
async def disconnect(self): async def disconnect(self):
"""Disconnect from the BLE device.""" """Disconnect from the BLE device."""

View file

@ -1,491 +0,0 @@
import asyncio
import logging
import random
from typing import Any, Dict, List, Optional, Union
from .events import Event, EventType
from .binary_commands import BinaryCommandHandler
# Define types for destination parameters
DestinationType = Union[bytes, str, Dict[str, Any]]
logger = logging.getLogger("meshcore")
def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes:
"""
Validates and converts a destination to a bytes object.
Args:
dst: The destination, which can be:
- str: Hex string representation of a public key
- dict: Contact object with a "public_key" field
prefix_length: The length of the prefix to use (default: 6 bytes)
Returns:
bytes: The destination public key as a bytes object
Raises:
ValueError: If dst is invalid or doesn't contain required fields
"""
if isinstance(dst, bytes):
# Already bytes, use directly
return dst[:prefix_length]
elif isinstance(dst, str):
# Hex string, convert to bytes
try:
return bytes.fromhex(dst)[:prefix_length]
except ValueError:
raise ValueError(f"Invalid public key hex string: {dst}")
elif isinstance(dst, dict):
# Contact object, extract public_key
if "public_key" not in dst:
raise ValueError("Contact object must have a 'public_key' field")
try:
return bytes.fromhex(dst["public_key"])[:prefix_length]
except ValueError:
raise ValueError(f"Invalid public_key in contact: {dst['public_key']}")
else:
raise ValueError(f"Destination must be a public key string or contact object, got: {type(dst)}")
class CommandHandler:
DEFAULT_TIMEOUT = 5.0
def __init__(self, default_timeout: Optional[float] = None):
self._sender_func = None
self._reader = None
self.dispatcher = None
self.binary = BinaryCommandHandler(self)
self.default_timeout = default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
def set_connection(self, connection: Any) -> None:
async def sender(data: bytes) -> None:
await connection.send(data)
self._sender_func = sender
def set_reader(self, reader: Any) -> None:
self._reader = reader
def set_dispatcher(self, dispatcher: Any) -> None:
self.dispatcher = dispatcher
async def send(self, data: bytes, expected_events: Optional[Union[EventType, List[EventType]]] = None,
timeout: Optional[float] = None) -> Event:
"""
Send a command and wait for expected event responses.
Args:
data: The data to send
expected_events: EventType or list of EventTypes to wait for
timeout: Timeout in seconds, or None to use default_timeout
Returns:
Event: The full event object that was received in response to the command
"""
if not self.dispatcher:
raise RuntimeError("Dispatcher not set, cannot send commands")
# Use the provided timeout or fall back to default_timeout
timeout = timeout if timeout is not None else self.default_timeout
if self._sender_func:
logger.debug(f"Sending raw data: {data.hex() if isinstance(data, bytes) else data}")
await self._sender_func(data)
if expected_events:
try:
# Convert single event to list if needed
if not isinstance(expected_events, list):
expected_events = [expected_events]
logger.debug(f"Waiting for events {expected_events}, timeout={timeout}")
# Create futures for all expected events
futures = []
for event_type in expected_events:
future = asyncio.create_task(
self.dispatcher.wait_for_event(event_type, {}, timeout)
)
futures.append(future)
# Wait for the first event to complete or all to timeout
done, pending = await asyncio.wait(
futures,
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED
)
# Cancel all pending futures
for future in pending:
future.cancel()
# Check if any future completed successfully
for future in done:
event = await future
if event:
return event
# Create an error event when no event is received
return Event(EventType.ERROR, {"reason": "no_event_received"})
except asyncio.TimeoutError:
logger.debug(f"Command timed out {data}")
return Event(EventType.ERROR, {"reason": "timeout"})
except Exception as e:
logger.debug(f"Command error: {e}")
return Event(EventType.ERROR, {"error": str(e)})
# For commands that don't expect events, return a success event
return Event(EventType.OK, {})
async def send_appstart(self) -> Event:
logger.debug("Sending appstart command")
b1 = bytearray(b'\x01\x03 mccli')
return await self.send(b1, [EventType.SELF_INFO])
async def send_device_query(self) -> Event:
logger.debug("Sending device query command")
return await self.send(b"\x16\x03", [EventType.DEVICE_INFO, EventType.ERROR])
async def send_advert(self, flood: bool = False) -> Event:
logger.debug(f"Sending advertisement command (flood={flood})")
if flood:
return await self.send(b"\x07\x01", [EventType.OK, EventType.ERROR])
else:
return await self.send(b"\x07", [EventType.OK, EventType.ERROR])
async def set_name(self, name: str) -> Event:
logger.debug(f"Setting device name to: {name}")
return await self.send(b'\x08' + name.encode("utf-8"), [EventType.OK, EventType.ERROR])
async def set_coords(self, lat: float, lon: float) -> Event:
logger.debug(f"Setting coordinates to: lat={lat}, lon={lon}")
return await self.send(b'\x0e'\
+ int(lat*1e6).to_bytes(4, 'little', signed=True)\
+ int(lon*1e6).to_bytes(4, 'little', signed=True)\
+ int(0).to_bytes(4, 'little'), [EventType.OK, EventType.ERROR])
async def reboot(self) -> Event:
logger.debug("Sending reboot command")
return await self.send(b'\x13reboot')
async def get_bat(self) -> Event:
logger.debug("Getting battery information")
return await self.send(b'\x14', [EventType.BATTERY, EventType.ERROR])
async def get_time(self) -> Event:
logger.debug("Getting device time")
return await self.send(b"\x05", [EventType.CURRENT_TIME, EventType.ERROR])
async def set_time(self, val: int) -> Event:
logger.debug(f"Setting device time to: {val}")
return await self.send(b"\x06" + int(val).to_bytes(4, 'little'), [EventType.OK, EventType.ERROR])
async def set_tx_power(self, val: int) -> Event:
logger.debug(f"Setting TX power to: {val}")
return await self.send(b"\x0c" + int(val).to_bytes(4, 'little'), [EventType.OK, EventType.ERROR])
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> Event:
logger.debug(f"Setting radio params: freq={freq}, bw={bw}, sf={sf}, cr={cr}")
return await self.send(b"\x0b" \
+ int(float(freq)*1000).to_bytes(4, 'little')\
+ int(float(bw)*1000).to_bytes(4, 'little')\
+ int(sf).to_bytes(1, 'little')\
+ int(cr).to_bytes(1, 'little'), [EventType.OK, EventType.ERROR])
async def set_tuning(self, rx_dly: int, af: int) -> Event:
logger.debug(f"Setting tuning params: rx_dly={rx_dly}, af={af}")
return await self.send(b"\x15" \
+ int(rx_dly).to_bytes(4, 'little')\
+ int(af).to_bytes(4, 'little')\
+ int(0).to_bytes(1, 'little')\
+ int(0).to_bytes(1, 'little'), [EventType.OK, EventType.ERROR])
async def set_other_params(self, manual_add_contacts : bool, telemetry_mode_base : int, telemetry_mode_loc : int, telemetry_mode_env : int, advert_loc_policy : int) :
telemetry_mode = (telemetry_mode_base & 0b11) | ((telemetry_mode_loc & 0b11) << 2) | ((telemetry_mode_env & 0b11) << 4)
data = b"\x26" + manual_add_contacts.to_bytes(1) + telemetry_mode.to_bytes(1) + advert_loc_policy.to_bytes(1)
return await self.send(data, [EventType.OK, EventType.ERROR])
async def set_telemetry_mode_base(self, telemetry_mode_base : int) :
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
telemetry_mode_base,
infos["telemetry_mode_loc"],
infos["telemetry_mode_env"],
infos["adv_loc_policy"])
async def set_telemetry_mode_loc(self, telemetry_mode_loc : int) :
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
infos["telemetry_mode_base"],
telemetry_mode_loc,
infos["telemetry_mode_env"],
infos["adv_loc_policy"])
async def set_telemetry_mode_env(self, telemetry_mode_env : int) :
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
infos["telemetry_mode_base"],
infos["telemetry_mode_loc"],
telemetry_mode_env,
infos["adv_loc_policy"])
async def set_manual_add_contacts(self, manual_add_contacts:bool) :
infos = (await self.send_appstart()).payload
return await self.set_other_params(
manual_add_contacts,
infos["telemetry_mode_base"],
infos["telemetry_mode_loc"],
infos["telemetry_mode_env"],
infos["adv_loc_policy"])
async def set_advert_loc_policy(self, advert_loc_policy:int) :
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
infos["telemetry_mode_base"],
infos["telemetry_mode_loc"],
infos["telemetry_mode_env"],
advert_loc_policy)
async def set_devicepin(self, pin: int) -> Event:
logger.debug(f"Setting device PIN to: {pin}")
return await self.send(b"\x25" \
+ int(pin).to_bytes(4, 'little'), [EventType.OK, EventType.ERROR])
async def get_contacts(self, lastmod=0) -> Event:
logger.debug("Getting contacts")
data=b"\x04"
if lastmod > 0:
data = data + lastmod.to_bytes(4, 'little')
return await self.send(data, [EventType.CONTACTS, EventType.ERROR])
async def reset_path(self, key: DestinationType) -> Event:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Resetting path for contact: {key_bytes.hex()}")
data = b"\x0D" + key_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def share_contact(self, key: DestinationType) -> Event:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Sharing contact: {key_bytes.hex()}")
data = b"\x10" + key_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def export_contact(self, key: Optional[DestinationType] = None) -> Event:
if key:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Exporting contact: {key_bytes.hex()}")
data = b"\x11" + key_bytes
else:
logger.debug("Exporting node")
data = b"\x11"
return await self.send(data, [EventType.CONTACT_URI, EventType.ERROR])
async def import_contact(self, card_data) -> Event:
data = b"\x12" + card_data
return await self.send(data, [EventType.OK, EventType.ERROR])
async def remove_contact(self, key: DestinationType) -> Event:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Removing contact: {key_bytes.hex()}")
data = b"\x0f" + key_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def update_contact (self, contact, path=None, flags=None) -> Event:
if path is None :
out_path_hex = contact["out_path"]
out_path_len = contact["out_path_len"]
else :
out_path_hex = path
out_path_len = int(len(path) / 2)
# reflect the change
contact["out_path"] = out_path_hex
contact["out_path_len"] = out_path_len
out_path_hex = out_path_hex + (128-len(out_path_hex)) * "0"
if flags is None :
flags = contact["flags"]
else :
# reflect the change
contact["flags"] = flags
adv_name_hex = contact["adv_name"].encode().hex()
adv_name_hex = adv_name_hex + (64-len(adv_name_hex)) * "0"
data = b"\x09" \
+ bytes.fromhex(contact["public_key"])\
+ contact["type"].to_bytes(1)\
+ flags.to_bytes(1)\
+ out_path_len.to_bytes(1, 'little', signed=True)\
+ bytes.fromhex(out_path_hex)\
+ bytes.fromhex(adv_name_hex)\
+ contact["last_advert"].to_bytes(4, 'little')\
+ int(contact["adv_lat"]*1e6).to_bytes(4, 'little', signed=True)\
+ int(contact["adv_lon"]*1e6).to_bytes(4, 'little', signed=True)
return await self.send(data, [EventType.OK, EventType.ERROR])
async def add_contact (self, contact) -> Event:
return await self.update_contact(contact)
async def change_contact_path (self, contact, path) -> Event:
return await self.update_contact(contact, path)
async def change_contact_flags (self, contact, flags) -> Event:
return await self.update_contact(contact, flags=flags)
async def get_msg(self, timeout: Optional[float] = None) -> Event:
logger.debug("Requesting pending messages")
return await self.send(b"\x0A", [EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV, EventType.ERROR, EventType.NO_MORE_MSGS], timeout)
async def send_login(self, dst: DestinationType, pwd: str) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Sending login request to: {dst_bytes.hex()}")
data = b"\x1a" + dst_bytes + pwd.encode("utf-8")
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_logout(self, dst: DestinationType) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
self.login_resp = asyncio.Future()
data = b"\x1d" + dst_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def send_statusreq(self, dst: DestinationType) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Sending status request to: {dst_bytes.hex()}")
data = b"\x1b" + dst_bytes
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_cmd(self, dst: DestinationType, cmd: str, timestamp: Optional[int] = None) -> Event:
dst_bytes = _validate_destination(dst)
logger.debug(f"Sending command to {dst_bytes.hex()}: {cmd}")
if timestamp is None:
import time
timestamp = int(time.time())
data = b"\x02\x01\x00" + timestamp.to_bytes(4, 'little') + dst_bytes + cmd.encode("utf-8")
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_msg(self, dst: DestinationType, msg: str, timestamp: Optional[int] = None) -> Event:
dst_bytes = _validate_destination(dst)
logger.debug(f"Sending message to {dst_bytes.hex()}: {msg}")
if timestamp is None:
import time
timestamp = int(time.time())
data = b"\x02\x00\x00" + timestamp.to_bytes(4, 'little') + dst_bytes + msg.encode("utf-8")
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_chan_msg(self, chan, msg, timestamp=None) -> Event:
logger.debug(f"Sending channel message to channel {chan}: {msg}")
# Default to current time if timestamp not provided
if timestamp is None:
import time
timestamp = int(time.time()).to_bytes(4, 'little')
data = b"\x03\x00" + chan.to_bytes(1, 'little') + timestamp + msg.encode("utf-8")
return await self.send(data, [EventType.OK, EventType.ERROR])
async def send_telemetry_req(self, dst: DestinationType) -> Event :
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Asking telemetry to {dst_bytes.hex()}")
data = b"\x27\x00\x00\x00" + dst_bytes
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_binary_req(self, dst: DestinationType, bin_data) -> Event :
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Binary request to {dst_bytes.hex()}")
data = b"\x32" + dst_bytes + bin_data
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_path_discovery(self, dst: DestinationType) -> Event :
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Path discovery request for {dst_bytes.hex()}")
data = b"\x34\x00" + dst_bytes
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def get_self_telemetry(self) -> Event :
logger.debug(f"Getting self telemetry")
data = b"\x27\x00\x00\x00"
return await self.send(data, [EventType.TELEMETRY_RESPONSE, EventType.ERROR])
async def get_custom_vars(self) -> Event:
logger.debug(f"Asking for custom vars")
data = b"\x28"
return await self.send(data, [EventType.CUSTOM_VARS, EventType.ERROR])
async def set_custom_var(self, key, value) -> Event:
logger.debug(f"Setting custom var {key} to {value}")
data = b"\x29" + key.encode("utf-8") + b":" + value.encode("utf-8")
return await self.send(data, [EventType.OK, EventType.ERROR])
async def get_channel(self, channel_idx: int) -> Event:
logger.debug(f"Getting channel info for channel {channel_idx}")
data = b"\x1f" + channel_idx.to_bytes(1, 'little')
return await self.send(data, [EventType.CHANNEL_INFO, EventType.ERROR])
async def set_channel(self, channel_idx: int, channel_name: str, channel_secret: bytes) -> Event:
logger.debug(f"Setting channel {channel_idx}: name={channel_name}")
# Pad channel name to 32 bytes
name_bytes = channel_name.encode('utf-8')[:32]
name_bytes = name_bytes.ljust(32, b'\x00')
# Ensure channel secret is exactly 16 bytes
if len(channel_secret) != 16:
raise ValueError("Channel secret must be exactly 16 bytes")
data = b"\x20" + channel_idx.to_bytes(1, 'little') + name_bytes + channel_secret
return await self.send(data, [EventType.OK, EventType.ERROR])
async def send_trace(self, auth_code: int = 0, tag: Optional[int] = None,
flags: int = 0, path: Optional[Union[str, bytes, bytearray]] = None) -> Event:
"""
Send a trace packet to test routing through specific repeaters
Args:
auth_code: 32-bit authentication code (default: 0)
tag: 32-bit integer to identify this trace (default: random)
flags: 8-bit flags field (default: 0)
path: Optional string with comma-separated hex values representing repeater pubkeys (e.g. "23,5f,3a")
or a bytes/bytearray object with the raw path data
Returns:
Event object with sent status, tag, and estimated timeout in milliseconds
"""
# Generate random tag if not provided
if tag is None:
tag = random.randint(1, 0xFFFFFFFF)
if auth_code is None:
auth_code = random.randint(1, 0xFFFFFFFF)
logger.debug(f"Sending trace: tag={tag}, auth={auth_code}, flags={flags}, path={path}")
# Prepare the command packet: CMD(1) + tag(4) + auth_code(4) + flags(1) + [path]
cmd_data = bytearray([36]) # CMD_SEND_TRACE_PATH
cmd_data.extend(tag.to_bytes(4, 'little'))
cmd_data.extend(auth_code.to_bytes(4, 'little'))
cmd_data.append(flags)
# Process path if provided
if path:
if isinstance(path, str):
# Convert comma-separated hex values to bytes
try:
path_bytes = bytearray()
for hex_val in path.split(','):
hex_val = hex_val.strip()
path_bytes.append(int(hex_val, 16))
cmd_data.extend(path_bytes)
except ValueError as e:
logger.error(f"Invalid path format: {e}")
return Event(EventType.ERROR, {"reason": "invalid_path_format"})
elif isinstance(path, (bytes, bytearray)):
cmd_data.extend(path)
else:
logger.error(f"Unsupported path type: {type(path)}")
return Event(EventType.ERROR, {"reason": "unsupported_path_type"})
return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])

View file

@ -0,0 +1,18 @@
from typing import Any, Optional
from ..events import EventDispatcher
from ..reader import MessageReader
from .base import CommandHandlerBase
from .binary import BinaryCommandHandler
from .contact import ContactCommands
from .device import DeviceCommands
from .messaging import MessagingCommands
class CommandHandler(
DeviceCommands, ContactCommands, MessagingCommands, BinaryCommandHandler
):
pass
__all__ = ["CommandHandler"]

View file

@ -0,0 +1,146 @@
import asyncio
import logging
import random
from typing import Any, Callable, Coroutine, Dict, List, Optional, Union
from ..events import Event, EventDispatcher, EventType
from ..reader import MessageReader
# Define types for destination parameters
DestinationType = Union[bytes, str, Dict[str, Any]]
logger = logging.getLogger("meshcore")
def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes:
"""
Validates and converts a destination to a bytes object.
Args:
dst: The destination, which can be:
- str: Hex string representation of a public key
- dict: Contact object with a "public_key" field
prefix_length: The length of the prefix to use (default: 6 bytes)
Returns:
bytes: The destination public key as a bytes object
Raises:
ValueError: If dst is invalid or doesn't contain required fields
"""
if isinstance(dst, bytes):
# Already bytes, use directly
return dst[:prefix_length]
elif isinstance(dst, str):
# Hex string, convert to bytes
try:
return bytes.fromhex(dst)[:prefix_length]
except ValueError:
raise ValueError(f"Invalid public key hex string: {dst}")
elif isinstance(dst, dict):
# Contact object, extract public_key
if "public_key" not in dst:
raise ValueError("Contact object must have a 'public_key' field")
try:
return bytes.fromhex(dst["public_key"])[:prefix_length]
except ValueError:
raise ValueError(f"Invalid public_key in contact: {dst['public_key']}")
else:
raise ValueError(
f"Destination must be a public key string or contact object, got: {type(dst)}"
)
class CommandHandlerBase:
DEFAULT_TIMEOUT = 5.0
def __init__(self, default_timeout: Optional[float] = None):
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
self._reader: Optional[MessageReader] = None
self.dispatcher: Optional[EventDispatcher] = None
self.default_timeout = (
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
)
def set_connection(self, connection: Any) -> None:
async def sender(data: bytes) -> None:
await connection.send(data)
self._sender_func = sender
def set_reader(self, reader: MessageReader) -> None:
self._reader = reader
def set_dispatcher(self, dispatcher: EventDispatcher) -> None:
self.dispatcher = dispatcher
async def send(
self,
data: bytes,
expected_events: Optional[Union[EventType, List[EventType]]] = None,
timeout: Optional[float] = None,
) -> Event:
"""
Send a command and wait for expected event responses.
Args:
data: The data to send
expected_events: EventType or list of EventTypes to wait for
timeout: Timeout in seconds, or None to use default_timeout
Returns:
Event: The full event object that was received in response to the command
"""
if not self.dispatcher:
raise RuntimeError("Dispatcher not set, cannot send commands")
# Use the provided timeout or fall back to default_timeout
timeout = timeout if timeout is not None else self.default_timeout
if self._sender_func:
logger.debug(
f"Sending raw data: {data.hex() if isinstance(data, bytes) else data}"
)
await self._sender_func(data)
if expected_events:
try:
# Convert single event to list if needed
if not isinstance(expected_events, list):
expected_events = [expected_events]
logger.debug(f"Waiting for events {expected_events}, timeout={timeout}")
# Create futures for all expected events
futures = []
for event_type in expected_events:
future = asyncio.create_task(
self.dispatcher.wait_for_event(event_type, {}, timeout)
)
futures.append(future)
# Wait for the first event to complete or all to timeout
done, pending = await asyncio.wait(
futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
)
# Cancel all pending futures
for future in pending:
future.cancel()
# Check if any future completed successfully
for future in done:
event = await future
if event:
return event
# Create an error event when no event is received
return Event(EventType.ERROR, {"reason": "no_event_received"})
except asyncio.TimeoutError:
logger.debug(f"Command timed out {data}")
return Event(EventType.ERROR, {"reason": "timeout"})
except Exception as e:
logger.debug(f"Command error: {e}")
return Event(EventType.ERROR, {"error": str(e)})
# For commands that don't expect events, return a success event
return Event(EventType.OK, {})

View file

@ -0,0 +1,126 @@
import logging
from enum import Enum
import json
from .base import CommandHandlerBase
from ..events import EventType
from cayennelpp import LppFrame, LppData
from cayennelpp.lpp_type import LppType
from ..lpp_json_encoder import lpp_json_encoder, my_lpp_types, lpp_format_val
logger = logging.getLogger("meshcore")
class BinaryReqType(Enum):
TELEMETRY = 0x03
MMA = 0x04
ACL = 0x05
def lpp_parse(buf):
"""Parse a given byte string and return as a LppFrame object."""
i = 0
lpp_data_list = []
while i < len(buf) and buf[i] != 0:
lppdata = LppData.from_bytes(buf[i:])
lpp_data_list.append(lppdata)
i = i + len(lppdata)
return json.loads(json.dumps(LppFrame(lpp_data_list), default=lpp_json_encoder))
def lpp_parse_mma(buf):
i = 0
res = []
while i < len(buf) and buf[i] != 0:
chan = buf[i]
i = i + 1
type = buf[i]
lpp_type = LppType.get_lpp_type(type)
size = lpp_type.size
i = i + 1
min = lpp_format_val(lpp_type, lpp_type.decode(buf[i : i + size]))
i = i + size
max = lpp_format_val(lpp_type, lpp_type.decode(buf[i : i + size]))
i = i + size
avg = lpp_format_val(lpp_type, lpp_type.decode(buf[i : i + size]))
i = i + size
res.append(
{
"channel": chan,
"type": my_lpp_types[type][0],
"min": min,
"max": max,
"avg": avg,
}
)
return res
def parse_acl(buf):
i = 0
res = []
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 = i + 7
return res
class BinaryCommandHandler(CommandHandlerBase):
"""Helper functions to handle binary requests through binary commands"""
async def req_binary(self, contact, request, timeout=0):
res = await self.send_binary_req(contact, request)
logger.debug(res)
if res.type == EventType.ERROR:
logger.error("Error while requesting binary data")
return None
else:
exp_tag = res.payload["expected_ack"].hex()
timeout = (
res.payload["suggested_timeout"] / 800 if timeout == 0 else timeout
)
res2 = await self.dispatcher.wait_for_event(
EventType.BINARY_RESPONSE,
attribute_filters={"tag": exp_tag},
timeout=timeout,
)
logger.debug(res2)
if res2 is None:
return None
else:
return res2.payload
async def req_telemetry(self, contact, timeout=0):
code = BinaryReqType.TELEMETRY.value
req = code.to_bytes(1, "little", signed=False)
res = await self.req_binary(contact, req, timeout)
if res is None:
return None
else:
return lpp_parse(bytes.fromhex(res["data"]))
async def req_mma(self, contact, start, end, timeout=0):
code = BinaryReqType.MMA.value
req = (
code.to_bytes(1, "little", signed=False)
+ start.to_bytes(4, "little", signed=False)
+ end.to_bytes(4, "little", signed=False)
+ b"\0\0"
)
res = await self.req_binary(contact, req, timeout)
if res is None:
return None
else:
return lpp_parse_mma(bytes.fromhex(res["data"])[4:])
async def req_acl(self, contact, timeout=0):
code = BinaryReqType.ACL.value
req = code.to_bytes(1, "little", signed=False) + b"\0\0"
res = await self.req_binary(contact, req, timeout)
if res is None:
return None
else:
return parse_acl(bytes.fromhex(res["data"]))

View file

@ -0,0 +1,91 @@
import logging
from typing import Optional
from ..events import Event, EventType
from .base import CommandHandlerBase, DestinationType, _validate_destination
logger = logging.getLogger("meshcore")
class ContactCommands(CommandHandlerBase):
async def get_contacts(self, lastmod=0) -> Event:
logger.debug("Getting contacts")
data = b"\x04"
if lastmod > 0:
data = data + lastmod.to_bytes(4, "little")
return await self.send(data, [EventType.CONTACTS, EventType.ERROR])
async def reset_path(self, key: DestinationType) -> Event:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Resetting path for contact: {key_bytes.hex()}")
data = b"\x0d" + key_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def share_contact(self, key: DestinationType) -> Event:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Sharing contact: {key_bytes.hex()}")
data = b"\x10" + key_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def export_contact(self, key: Optional[DestinationType] = None) -> Event:
if key:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Exporting contact: {key_bytes.hex()}")
data = b"\x11" + key_bytes
else:
logger.debug("Exporting node")
data = b"\x11"
return await self.send(data, [EventType.CONTACT_URI, EventType.ERROR])
async def import_contact(self, card_data) -> Event:
data = b"\x12" + card_data
return await self.send(data, [EventType.OK, EventType.ERROR])
async def remove_contact(self, key: DestinationType) -> Event:
key_bytes = _validate_destination(key, prefix_length=32)
logger.debug(f"Removing contact: {key_bytes.hex()}")
data = b"\x0f" + key_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def update_contact(self, contact, path=None, flags=None) -> Event:
if path is None:
out_path_hex = contact["out_path"]
out_path_len = contact["out_path_len"]
else:
out_path_hex = path
out_path_len = int(len(path) / 2)
# reflect the change
contact["out_path"] = out_path_hex
contact["out_path_len"] = out_path_len
out_path_hex = out_path_hex + (128 - len(out_path_hex)) * "0"
if flags is None:
flags = contact["flags"]
else:
# reflect the change
contact["flags"] = flags
adv_name_hex = contact["adv_name"].encode().hex()
adv_name_hex = adv_name_hex + (64 - len(adv_name_hex)) * "0"
data = (
b"\x09"
+ bytes.fromhex(contact["public_key"])
+ contact["type"].to_bytes(1)
+ flags.to_bytes(1)
+ out_path_len.to_bytes(1, "little", signed=True)
+ bytes.fromhex(out_path_hex)
+ bytes.fromhex(adv_name_hex)
+ contact["last_advert"].to_bytes(4, "little")
+ int(contact["adv_lat"] * 1e6).to_bytes(4, "little", signed=True)
+ int(contact["adv_lon"] * 1e6).to_bytes(4, "little", signed=True)
)
return await self.send(data, [EventType.OK, EventType.ERROR])
async def add_contact(self, contact) -> Event:
return await self.update_contact(contact)
async def change_contact_path(self, contact, path) -> Event:
return await self.update_contact(contact, path)
async def change_contact_flags(self, contact, flags) -> Event:
return await self.update_contact(contact, flags=flags)

View file

@ -0,0 +1,200 @@
import logging
from typing import Optional
from ..events import Event, EventType
from .base import CommandHandlerBase, DestinationType, _validate_destination
logger = logging.getLogger("meshcore")
class DeviceCommands(CommandHandlerBase):
async def send_appstart(self) -> Event:
logger.debug("Sending appstart command")
b1 = bytearray(b"\x01\x03 mccli")
return await self.send(b1, [EventType.SELF_INFO])
async def send_device_query(self) -> Event:
logger.debug("Sending device query command")
return await self.send(b"\x16\x03", [EventType.DEVICE_INFO, EventType.ERROR])
async def send_advert(self, flood: bool = False) -> Event:
logger.debug(f"Sending advertisement command (flood={flood})")
if flood:
return await self.send(b"\x07\x01", [EventType.OK, EventType.ERROR])
else:
return await self.send(b"\x07", [EventType.OK, EventType.ERROR])
async def set_name(self, name: str) -> Event:
logger.debug(f"Setting device name to: {name}")
return await self.send(
b"\x08" + name.encode("utf-8"), [EventType.OK, EventType.ERROR]
)
async def set_coords(self, lat: float, lon: float) -> Event:
logger.debug(f"Setting coordinates to: lat={lat}, lon={lon}")
return await self.send(
b"\x0e"
+ int(lat * 1e6).to_bytes(4, "little", signed=True)
+ int(lon * 1e6).to_bytes(4, "little", signed=True)
+ int(0).to_bytes(4, "little"),
[EventType.OK, EventType.ERROR],
)
async def reboot(self) -> Event:
logger.debug("Sending reboot command")
return await self.send(b"\x13reboot")
async def get_bat(self) -> Event:
logger.debug("Getting battery information")
return await self.send(b"\x14", [EventType.BATTERY, EventType.ERROR])
async def get_time(self) -> Event:
logger.debug("Getting device time")
return await self.send(b"\x05", [EventType.CURRENT_TIME, EventType.ERROR])
async def set_time(self, val: int) -> Event:
logger.debug(f"Setting device time to: {val}")
return await self.send(
b"\x06" + int(val).to_bytes(4, "little"), [EventType.OK, EventType.ERROR]
)
async def set_tx_power(self, val: int) -> Event:
logger.debug(f"Setting TX power to: {val}")
return await self.send(
b"\x0c" + int(val).to_bytes(4, "little"), [EventType.OK, EventType.ERROR]
)
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> Event:
logger.debug(f"Setting radio params: freq={freq}, bw={bw}, sf={sf}, cr={cr}")
return await self.send(
b"\x0b"
+ int(float(freq) * 1000).to_bytes(4, "little")
+ int(float(bw) * 1000).to_bytes(4, "little")
+ int(sf).to_bytes(1, "little")
+ int(cr).to_bytes(1, "little"),
[EventType.OK, EventType.ERROR],
)
async def set_tuning(self, rx_dly: int, af: int) -> Event:
logger.debug(f"Setting tuning params: rx_dly={rx_dly}, af={af}")
return await self.send(
b"\x15"
+ int(rx_dly).to_bytes(4, "little")
+ int(af).to_bytes(4, "little")
+ int(0).to_bytes(1, "little")
+ int(0).to_bytes(1, "little"),
[EventType.OK, EventType.ERROR],
)
async def set_other_params(
self,
manual_add_contacts: bool,
telemetry_mode_base: int,
telemetry_mode_loc: int,
telemetry_mode_env: int,
advert_loc_policy: int,
) -> Event:
telemetry_mode = (
(telemetry_mode_base & 0b11)
| ((telemetry_mode_loc & 0b11) << 2)
| ((telemetry_mode_env & 0b11) << 4)
)
data = (
b"\x26"
+ manual_add_contacts.to_bytes(1)
+ telemetry_mode.to_bytes(1)
+ advert_loc_policy.to_bytes(1)
)
return await self.send(data, [EventType.OK, EventType.ERROR])
async def set_telemetry_mode_base(self, telemetry_mode_base: int) -> Event:
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
telemetry_mode_base,
infos["telemetry_mode_loc"],
infos["telemetry_mode_env"],
infos["adv_loc_policy"],
)
async def set_telemetry_mode_loc(self, telemetry_mode_loc: int) -> Event:
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
infos["telemetry_mode_base"],
telemetry_mode_loc,
infos["telemetry_mode_env"],
infos["adv_loc_policy"],
)
async def set_telemetry_mode_env(self, telemetry_mode_env: int) -> Event:
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
infos["telemetry_mode_base"],
infos["telemetry_mode_loc"],
telemetry_mode_env,
infos["adv_loc_policy"],
)
async def set_manual_add_contacts(self, manual_add_contacts: bool) -> Event:
infos = (await self.send_appstart()).payload
return await self.set_other_params(
manual_add_contacts,
infos["telemetry_mode_base"],
infos["telemetry_mode_loc"],
infos["telemetry_mode_env"],
infos["adv_loc_policy"],
)
async def set_advert_loc_policy(self, advert_loc_policy: int) -> Event:
infos = (await self.send_appstart()).payload
return await self.set_other_params(
infos["manual_add_contacts"],
infos["telemetry_mode_base"],
infos["telemetry_mode_loc"],
infos["telemetry_mode_env"],
advert_loc_policy,
)
async def set_devicepin(self, pin: int) -> Event:
logger.debug(f"Setting device PIN to: {pin}")
return await self.send(
b"\x25" + int(pin).to_bytes(4, "little"), [EventType.OK, EventType.ERROR]
)
async def get_self_telemetry(self) -> Event:
logger.debug("Getting self telemetry")
data = b"\x27\x00\x00\x00"
return await self.send(data, [EventType.TELEMETRY_RESPONSE, EventType.ERROR])
async def get_custom_vars(self) -> Event:
logger.debug("Asking for custom vars")
data = b"\x28"
return await self.send(data, [EventType.CUSTOM_VARS, EventType.ERROR])
async def set_custom_var(self, key, value) -> Event:
logger.debug(f"Setting custom var {key} to {value}")
data = b"\x29" + key.encode("utf-8") + b":" + value.encode("utf-8")
return await self.send(data, [EventType.OK, EventType.ERROR])
async def get_channel(self, channel_idx: int) -> Event:
logger.debug(f"Getting channel info for channel {channel_idx}")
data = b"\x1f" + channel_idx.to_bytes(1, "little")
return await self.send(data, [EventType.CHANNEL_INFO, EventType.ERROR])
async def set_channel(
self, channel_idx: int, channel_name: str, channel_secret: bytes
) -> Event:
logger.debug(f"Setting channel {channel_idx}: name={channel_name}")
# Pad channel name to 32 bytes
name_bytes = channel_name.encode("utf-8")[:32]
name_bytes = name_bytes.ljust(32, b"\x00")
# Ensure channel secret is exactly 16 bytes
if len(channel_secret) != 16:
raise ValueError("Channel secret must be exactly 16 bytes")
data = b"\x20" + channel_idx.to_bytes(1, "little") + name_bytes + channel_secret
return await self.send(data, [EventType.OK, EventType.ERROR])

View file

@ -0,0 +1,167 @@
import logging
import random
from typing import Optional, Union
from ..events import Event, EventType
from .base import CommandHandlerBase, DestinationType, _validate_destination
logger = logging.getLogger("meshcore")
class MessagingCommands(CommandHandlerBase):
async def get_msg(self, timeout: Optional[float] = None) -> Event:
logger.debug("Requesting pending messages")
return await self.send(
b"\x0a",
[
EventType.CONTACT_MSG_RECV,
EventType.CHANNEL_MSG_RECV,
EventType.ERROR,
EventType.NO_MORE_MSGS,
],
timeout,
)
async def send_login(self, dst: DestinationType, pwd: str) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Sending login request to: {dst_bytes.hex()}")
data = b"\x1a" + dst_bytes + pwd.encode("utf-8")
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_logout(self, dst: DestinationType) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
data = b"\x1d" + dst_bytes
return await self.send(data, [EventType.OK, EventType.ERROR])
async def send_statusreq(self, dst: DestinationType) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Sending status request to: {dst_bytes.hex()}")
data = b"\x1b" + dst_bytes
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_cmd(
self, dst: DestinationType, cmd: str, timestamp: Optional[int] = None
) -> Event:
dst_bytes = _validate_destination(dst)
logger.debug(f"Sending command to {dst_bytes.hex()}: {cmd}")
if timestamp is None:
import time
timestamp = int(time.time())
data = (
b"\x02\x01\x00"
+ timestamp.to_bytes(4, "little")
+ dst_bytes
+ cmd.encode("utf-8")
)
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_msg(
self, dst: DestinationType, msg: str, timestamp: Optional[int] = None
) -> Event:
dst_bytes = _validate_destination(dst)
logger.debug(f"Sending message to {dst_bytes.hex()}: {msg}")
if timestamp is None:
import time
timestamp = int(time.time())
data = (
b"\x02\x00\x00"
+ timestamp.to_bytes(4, "little")
+ dst_bytes
+ msg.encode("utf-8")
)
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_chan_msg(self, chan, msg, timestamp=None) -> Event:
logger.debug(f"Sending channel message to channel {chan}: {msg}")
# Default to current time if timestamp not provided
if timestamp is None:
import time
timestamp = int(time.time()).to_bytes(4, "little")
data = (
b"\x03\x00" + chan.to_bytes(1, "little") + timestamp + msg.encode("utf-8")
)
return await self.send(data, [EventType.OK, EventType.ERROR])
async def send_telemetry_req(self, dst: DestinationType) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Asking telemetry to {dst_bytes.hex()}")
data = b"\x27\x00\x00\x00" + dst_bytes
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_binary_req(self, dst: DestinationType, bin_data) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Binary request to {dst_bytes.hex()}")
data = b"\x32" + dst_bytes + bin_data
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_path_discovery(self, dst: DestinationType) -> Event:
dst_bytes = _validate_destination(dst, prefix_length=32)
logger.debug(f"Path discovery request for {dst_bytes.hex()}")
data = b"\x34\x00" + dst_bytes
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
async def send_trace(
self,
auth_code: int = 0,
tag: Optional[int] = None,
flags: int = 0,
path: Optional[Union[str, bytes, bytearray]] = None,
) -> Event:
"""
Send a trace packet to test routing through specific repeaters
Args:
auth_code: 32-bit authentication code (default: 0)
tag: 32-bit integer to identify this trace (default: random)
flags: 8-bit flags field (default: 0)
path: Optional string with comma-separated hex values representing repeater pubkeys (e.g. "23,5f,3a")
or a bytes/bytearray object with the raw path data
Returns:
Event object with sent status, tag, and estimated timeout in milliseconds
"""
# Generate random tag if not provided
if tag is None:
tag = random.randint(1, 0xFFFFFFFF)
if auth_code is None:
auth_code = random.randint(1, 0xFFFFFFFF)
logger.debug(
f"Sending trace: tag={tag}, auth={auth_code}, flags={flags}, path={path}"
)
# Prepare the command packet: CMD(1) + tag(4) + auth_code(4) + flags(1) + [path]
cmd_data = bytearray([36]) # CMD_SEND_TRACE_PATH
cmd_data.extend(tag.to_bytes(4, "little"))
cmd_data.extend(auth_code.to_bytes(4, "little"))
cmd_data.append(flags)
# Process path if provided
if path:
if isinstance(path, str):
# Convert comma-separated hex values to bytes
try:
path_bytes = bytearray()
for hex_val in path.split(","):
hex_val = hex_val.strip()
path_bytes.append(int(hex_val, 16))
cmd_data.extend(path_bytes)
except ValueError as e:
logger.error(f"Invalid path format: {e}")
return Event(EventType.ERROR, {"reason": "invalid_path_format"})
elif isinstance(path, (bytes, bytearray)):
cmd_data.extend(path)
else:
logger.error(f"Unsupported path type: {type(path)}")
return Event(EventType.ERROR, {"reason": "unsupported_path_type"})
return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR])

View file

@ -1,6 +1,7 @@
""" """
Connection manager that orchestrates reconnection logic for any connection type. Connection manager that orchestrates reconnection logic for any connection type.
""" """
import asyncio import asyncio
import logging import logging
from typing import Optional, Any, Callable, Protocol from typing import Optional, Any, Callable, Protocol
@ -8,6 +9,7 @@ from .events import Event, EventType
logger = logging.getLogger("meshcore") logger = logging.getLogger("meshcore")
class ConnectionProtocol(Protocol): class ConnectionProtocol(Protocol):
"""Protocol defining the interface that connection classes must implement.""" """Protocol defining the interface that connection classes must implement."""
@ -31,8 +33,13 @@ class ConnectionProtocol(Protocol):
class ConnectionManager: class ConnectionManager:
"""Manages connection lifecycle with auto-reconnect and event emission.""" """Manages connection lifecycle with auto-reconnect and event emission."""
def __init__(self, connection: ConnectionProtocol, event_dispatcher=None, def __init__(
auto_reconnect: bool = False, max_reconnect_attempts: int = 3): self,
connection: ConnectionProtocol,
event_dispatcher=None,
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
):
self.connection = connection self.connection = connection
self.event_dispatcher = event_dispatcher self.event_dispatcher = event_dispatcher
self.auto_reconnect = auto_reconnect self.auto_reconnect = auto_reconnect
@ -74,7 +81,9 @@ class ConnectionManager:
if self._is_connected: if self._is_connected:
await self.connection.disconnect() await self.connection.disconnect()
self._is_connected = False self._is_connected = False
await self._emit_event(EventType.DISCONNECTED, {"reason": "manual_disconnect"}) await self._emit_event(
EventType.DISCONNECTED, {"reason": "manual_disconnect"}
)
async def handle_disconnect(self, reason: str = "unknown"): async def handle_disconnect(self, reason: str = "unknown"):
"""Handle unexpected disconnections with optional auto-reconnect.""" """Handle unexpected disconnections with optional auto-reconnect."""
@ -84,17 +93,26 @@ class ConnectionManager:
self._is_connected = False self._is_connected = False
logger.debug(f"Connection lost: {reason}") logger.debug(f"Connection lost: {reason}")
if self.auto_reconnect and self._reconnect_attempts < self.max_reconnect_attempts: if (
self.auto_reconnect
and self._reconnect_attempts < self.max_reconnect_attempts
):
self._reconnect_task = asyncio.create_task(self._attempt_reconnect()) self._reconnect_task = asyncio.create_task(self._attempt_reconnect())
else: else:
await self._emit_event(EventType.DISCONNECTED, { await self._emit_event(
"reason": reason, EventType.DISCONNECTED,
"reconnect_failed": self._reconnect_attempts >= self.max_reconnect_attempts {
}) "reason": reason,
"reconnect_failed": self._reconnect_attempts
>= self.max_reconnect_attempts,
},
)
async def _attempt_reconnect(self): async def _attempt_reconnect(self):
"""Attempt to reconnect with flat delay.""" """Attempt to reconnect with flat delay."""
logger.debug(f"Attempting reconnection ({self._reconnect_attempts + 1}/{self.max_reconnect_attempts})") logger.debug(
f"Attempting reconnection ({self._reconnect_attempts + 1}/{self.max_reconnect_attempts})"
)
self._reconnect_attempts += 1 self._reconnect_attempts += 1
# Flat 1 second delay for all attempts # Flat 1 second delay for all attempts
@ -105,29 +123,31 @@ class ConnectionManager:
if result is not None: if result is not None:
self._is_connected = True self._is_connected = True
self._reconnect_attempts = 0 self._reconnect_attempts = 0
await self._emit_event(EventType.CONNECTED, { await self._emit_event(
"connection_info": result, EventType.CONNECTED,
"reconnected": True {"connection_info": result, "reconnected": True},
}) )
logger.debug(f"Reconnected successfully") logger.debug("Reconnected successfully")
else: else:
# Reconnection failed, try again if we haven't exceeded max attempts # Reconnection failed, try again if we haven't exceeded max attempts
if self._reconnect_attempts < self.max_reconnect_attempts: if self._reconnect_attempts < self.max_reconnect_attempts:
self._reconnect_task = asyncio.create_task(self._attempt_reconnect()) self._reconnect_task = asyncio.create_task(
self._attempt_reconnect()
)
else: else:
await self._emit_event(EventType.DISCONNECTED, { await self._emit_event(
"reason": "reconnect_failed", EventType.DISCONNECTED,
"max_attempts_exceeded": True {"reason": "reconnect_failed", "max_attempts_exceeded": True},
}) )
except Exception as e: except Exception as e:
logger.debug(f"Reconnection attempt failed: {e}") logger.debug(f"Reconnection attempt failed: {e}")
if self._reconnect_attempts < self.max_reconnect_attempts: if self._reconnect_attempts < self.max_reconnect_attempts:
self._reconnect_task = asyncio.create_task(self._attempt_reconnect()) self._reconnect_task = asyncio.create_task(self._attempt_reconnect())
else: else:
await self._emit_event(EventType.DISCONNECTED, { await self._emit_event(
"reason": f"reconnect_error: {e}", EventType.DISCONNECTED,
"max_attempts_exceeded": True {"reason": f"reconnect_error: {e}", "max_attempts_exceeded": True},
}) )
async def _emit_event(self, event_type: EventType, payload: dict): async def _emit_event(self, event_type: EventType, payload: dict):
"""Emit connection events if dispatcher is available.""" """Emit connection events if dispatcher is available."""

View file

@ -1,12 +1,12 @@
from enum import Enum from enum import Enum
import logging import logging
from math import log
from typing import Any, Dict, Optional, Callable, List, Union from typing import Any, Dict, Optional, Callable, List, Union
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
logger = logging.getLogger("meshcore") logger = logging.getLogger("meshcore")
# Public event types for users to subscribe to # Public event types for users to subscribe to
class EventType(Enum): class EventType(Enum):
CONTACTS = "contacts" CONTACTS = "contacts"
@ -54,7 +54,13 @@ class Event:
payload: Any payload: Any
attributes: Dict[str, Any] = field(default_factory=dict) attributes: Dict[str, Any] = field(default_factory=dict)
def __init__(self, type: EventType, payload: Any, attributes: Optional[Dict[str, Any]] = None, **kwargs): def __init__(
self,
type: EventType,
payload: Any,
attributes: Optional[Dict[str, Any]] = None,
**kwargs,
):
""" """
Initialize an Event Initialize an Event
@ -71,6 +77,7 @@ class Event:
# Add any keyword arguments to the attributes dictionary # Add any keyword arguments to the attributes dictionary
if kwargs: if kwargs:
self.attributes.update(kwargs) self.attributes.update(kwargs)
def clone(self): def clone(self):
""" """
Create a copy of the event. Create a copy of the event.
@ -78,7 +85,9 @@ class Event:
Returns: Returns:
A new Event object with the same type, payload, and attributes. A new Event object with the same type, payload, and attributes.
""" """
copied_payload = self.payload.copy() if isinstance(self.payload, dict) else self.payload copied_payload = (
self.payload.copy() if isinstance(self.payload, dict) else self.payload
)
return Event(self.type, copied_payload, self.attributes.copy()) return Event(self.type, copied_payload, self.attributes.copy())
@ -100,8 +109,12 @@ class EventDispatcher:
self.running = False self.running = False
self._task = None self._task = None
def subscribe(self, event_type: Union[EventType, None], callback: Callable[[Event], Union[None, asyncio.Future]], def subscribe(
attribute_filters: Optional[Dict[str, Any]] = None) -> Subscription: self,
event_type: Union[EventType, None],
callback: Callable[[Event], Union[None, asyncio.Future]],
attribute_filters: Optional[Dict[str, Any]] = None,
) -> Subscription:
""" """
Subscribe to events with optional attribute filtering. Subscribe to events with optional attribute filtering.
@ -132,15 +145,25 @@ class EventDispatcher:
async def _process_events(self): async def _process_events(self):
while self.running: while self.running:
event = await self.queue.get() event = await self.queue.get()
logger.debug(f"Dispatching event: {event.type}, {event.payload}, {event.attributes}") logger.debug(
f"Dispatching event: {event.type}, {event.payload}, {event.attributes}"
)
for subscription in self.subscriptions.copy(): for subscription in self.subscriptions.copy():
# Check if event type matches # Check if event type matches
if subscription.event_type is None or subscription.event_type == event.type: if (
subscription.event_type is None
or subscription.event_type == event.type
):
# Check if all attribute filters match # Check if all attribute filters match
if subscription.attribute_filters and subscription.attribute_filters != {}: if (
subscription.attribute_filters
and subscription.attribute_filters != {}
):
# Skip if any filter doesn't match the corresponding event attribute # Skip if any filter doesn't match the corresponding event attribute
if not all(event.attributes.get(key) == value if not all(
for key, value in subscription.attribute_filters.items()): event.attributes.get(key) == value
for key, value in subscription.attribute_filters.items()
):
continue continue
try: try:
result = subscription.callback(event.clone()) result = subscription.callback(event.clone())
@ -168,8 +191,12 @@ class EventDispatcher:
pass pass
self._task = None self._task = None
async def wait_for_event(self, event_type: EventType, attribute_filters: Optional[Dict[str, Any]] = None, async def wait_for_event(
timeout: float | None = None) -> Optional[Event]: self,
event_type: EventType,
attribute_filters: Optional[Dict[str, Any]] = None,
timeout: float | None = None,
) -> Optional[Event]:
""" """
Wait for an event of the specified type that matches all attribute filters. Wait for an event of the specified type that matches all attribute filters.

View file

@ -4,58 +4,61 @@ from cayennelpp.lpp_type import LppType
# Format : type name "how to display value" # Format : type name "how to display value"
# display : None: (use lib default), []: only one value to display, ["field1", "field2" ...]: meaning of each field # display : None: (use lib default), []: only one value to display, ["field1", "field2" ...]: meaning of each field
my_lpp_types = { my_lpp_types = {
0: ('digital input', []), 0: ("digital input", []),
1: ('digital output', []), 1: ("digital output", []),
2: ('analog input', []), 2: ("analog input", []),
3: ('analog output', []), 3: ("analog output", []),
100: ('generic sensor', []), 100: ("generic sensor", []),
101: ('illuminance', []), 101: ("illuminance", []),
102: ('presence', []), 102: ("presence", []),
103: ('temperature', []), 103: ("temperature", []),
104: ('humidity', []), 104: ("humidity", []),
113: ('accelerometer', ["acc_x", "acc_y", "acc_z"]), 113: ("accelerometer", ["acc_x", "acc_y", "acc_z"]),
115: ('barometer', []), 115: ("barometer", []),
116: ('voltage', []), 116: ("voltage", []),
117: ('current', []), 117: ("current", []),
118: ('frequency', []), 118: ("frequency", []),
120: ('percentage', []), 120: ("percentage", []),
121: ('altitude', []), 121: ("altitude", []),
122: ('load', []), 122: ("load", []),
125: ('concentration', []), 125: ("concentration", []),
128: ('power', []), 128: ("power", []),
130: ('distance', []), 130: ("distance", []),
131: ('energy', []), 131: ("energy", []),
132: ('direction', None), 132: ("direction", None),
133: ('time', []), 133: ("time", []),
134: ('gyrometer', None), 134: ("gyrometer", None),
135: ('colour', ["red", "green", "blue"]), 135: ("colour", ["red", "green", "blue"]),
136: ('gps', ["latitude", "longitude", "altitude"]), 136: ("gps", ["latitude", "longitude", "altitude"]),
142: ('switch', []), 142: ("switch", []),
} }
def lpp_format_val(type, val): def lpp_format_val(type, val):
if my_lpp_types[type.type][1] is None : if my_lpp_types[type.type][1] is None:
return val return val
if len(my_lpp_types[type.type][1]) == 0 : if len(my_lpp_types[type.type][1]) == 0:
return val[0] return val[0]
val_dict = {} val_dict = {}
i = 0 i = 0
for t in my_lpp_types[type.type][1] : for t in my_lpp_types[type.type][1]:
val_dict[t] = val[i] val_dict[t] = val[i]
i = i + 1 i = i + 1
return val_dict return val_dict
def lpp_json_encoder (obj, types = my_lpp_types) :
def lpp_json_encoder(obj, types=my_lpp_types):
"""Encode LppType, LppData, and LppFrame to JSON.""" """Encode LppType, LppData, and LppFrame to JSON."""
if isinstance(obj, LppFrame): if isinstance(obj, LppFrame):
return obj.data return obj.data
if isinstance(obj, LppType): if isinstance(obj, LppType):
return my_lpp_types[obj.type][0] return my_lpp_types[obj.type][0]
if isinstance(obj, LppData): if isinstance(obj, LppData):
return {"channel" : obj.channel, return {
"type" : obj.type, "channel": obj.channel,
"value" : lpp_format_val(obj.type, obj.value) "type": obj.type,
"value": lpp_format_val(obj.type, obj.value),
} }
raise TypeError(repr(obj) + " is not JSON serialized") raise TypeError(repr(obj) + " is not JSON serialized")

View file

@ -1,8 +1,8 @@
import asyncio import asyncio
import logging import logging
from typing import Optional, Dict, Any, Union from typing import Any, Callable, Coroutine, Dict, Optional, Union
from .events import EventDispatcher, EventType from .events import Event, EventDispatcher, EventType, Subscription
from .reader import MessageReader from .reader import MessageReader
from .commands import CommandHandler from .commands import CommandHandler
from .connection_manager import ConnectionManager from .connection_manager import ConnectionManager
@ -13,11 +13,21 @@ from .serial_cx import SerialConnection
# Setup default logger # Setup default logger
logger = logging.getLogger("meshcore") logger = logging.getLogger("meshcore")
class MeshCore: class MeshCore:
""" """
Interface to a MeshCore device Interface to a MeshCore device
""" """
def __init__(self, cx, debug=False, only_error=False, default_timeout=None, auto_reconnect=False, max_reconnect_attempts=3):
def __init__(
self,
cx: Union[BLEConnection, TCPConnection, SerialConnection],
debug: bool = False,
only_error: bool = False,
default_timeout: Optional[float] = None,
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
):
# Wrap connection with ConnectionManager # Wrap connection with ConnectionManager
self.dispatcher = EventDispatcher() self.dispatcher = EventDispatcher()
self.connection_manager = ConnectionManager( self.connection_manager = ConnectionManager(
@ -61,30 +71,67 @@ class MeshCore:
cx.set_disconnect_callback(self.connection_manager.handle_disconnect) cx.set_disconnect_callback(self.connection_manager.handle_disconnect)
@classmethod @classmethod
async def create_tcp(cls, host: str, port: int, debug: bool = False, only_error:bool = False, default_timeout=None, async def create_tcp(
auto_reconnect: bool = False, max_reconnect_attempts: int = 3) -> 'MeshCore': cls,
host: str,
port: int,
debug: bool = False,
only_error: bool = False,
default_timeout=None,
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
) -> "MeshCore":
"""Create and connect a MeshCore instance using TCP connection""" """Create and connect a MeshCore instance using TCP connection"""
connection = TCPConnection(host, port) connection = TCPConnection(host, port)
mc = cls(connection, debug=debug, only_error=only_error, default_timeout=default_timeout, mc = cls(
auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts) connection,
debug=debug,
only_error=only_error,
default_timeout=default_timeout,
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
)
await mc.connect() await mc.connect()
return mc return mc
@classmethod @classmethod
async def create_serial(cls, port: str, baudrate: int = 115200, debug: bool = False, only_error:bool=False, default_timeout=None, async def create_serial(
auto_reconnect: bool = False, max_reconnect_attempts: int = 3, cx_dly:float = 0.1) -> 'MeshCore': cls,
port: str,
baudrate: int = 115200,
debug: bool = False,
only_error: bool = False,
default_timeout=None,
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
cx_dly: float = 0.1,
) -> "MeshCore":
"""Create and connect a MeshCore instance using serial connection""" """Create and connect a MeshCore instance using serial connection"""
connection = SerialConnection(port, baudrate, cx_dly=cx_dly) connection = SerialConnection(port, baudrate, cx_dly=cx_dly)
mc = cls(connection, debug=debug, only_error=only_error, default_timeout=default_timeout, mc = cls(
auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts) connection,
debug=debug,
only_error=only_error,
default_timeout=default_timeout,
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
)
await mc.connect() await mc.connect()
return mc return mc
@classmethod @classmethod
async def create_ble(cls, address: Optional[str] = None, client=None, debug: bool = False, only_error:bool=False, default_timeout=None, async def create_ble(
auto_reconnect: bool = False, max_reconnect_attempts: int = 3) -> 'MeshCore': cls,
address: Optional[str] = None,
client=None,
debug: bool = False,
only_error: bool = False,
default_timeout=None,
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
) -> "MeshCore":
""" """
Create and connect a MeshCore instance using BLE connection. Create and connect a MeshCore instance using BLE connection.
@ -97,8 +144,14 @@ class MeshCore:
connection = BLEConnection(address=address, client=client) connection = BLEConnection(address=address, client=client)
mc = cls(connection, debug=debug, only_error=only_error, default_timeout=default_timeout, mc = cls(
auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts) connection,
debug=debug,
only_error=only_error,
default_timeout=default_timeout,
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
)
await mc.connect() await mc.connect()
return mc return mc
@ -115,7 +168,7 @@ class MeshCore:
await self.dispatcher.stop() await self.dispatcher.stop()
# Stop auto message fetching if it's running # Stop auto message fetching if it's running
if hasattr(self, '_auto_fetch_subscription') and self._auto_fetch_subscription: if hasattr(self, "_auto_fetch_subscription") and self._auto_fetch_subscription:
await self.stop_auto_message_fetching() await self.stop_auto_message_fetching()
# Disconnect the connection object # Disconnect the connection object
@ -127,7 +180,12 @@ class MeshCore:
self.dispatcher.running = False self.dispatcher.running = False
self.dispatcher._task.cancel() self.dispatcher._task.cancel()
def subscribe(self, event_type: Union[EventType, None], callback, attribute_filters: Optional[Dict[str, Any]] = None): def subscribe(
self,
event_type: Union[EventType, None],
callback: Callable[[Event], Coroutine[Any, Any, None]],
attribute_filters: Optional[Dict[str, Any]] = None,
) -> Subscription:
""" """
Subscribe to events using EventType enum with optional attribute filtering Subscribe to events using EventType enum with optional attribute filtering
@ -149,7 +207,7 @@ class MeshCore:
""" """
return self.dispatcher.subscribe(event_type, callback, attribute_filters) return self.dispatcher.subscribe(event_type, callback, attribute_filters)
def unsubscribe(self, subscription): def unsubscribe(self, subscription: Subscription) -> None:
""" """
Unsubscribe from events using a subscription object Unsubscribe from events using a subscription object
@ -159,7 +217,12 @@ class MeshCore:
if subscription: if subscription:
subscription.unsubscribe() subscription.unsubscribe()
async def wait_for_event(self, event_type: EventType, attribute_filters: Optional[Dict[str, Any]] = None, timeout=None): async def wait_for_event(
self,
event_type: EventType,
attribute_filters: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
) -> Optional[Event]:
""" """
Wait for an event using EventType enum with optional attribute filtering Wait for an event using EventType enum with optional attribute filtering
@ -183,19 +246,22 @@ class MeshCore:
if timeout is None: if timeout is None:
timeout = self.default_timeout timeout = self.default_timeout
return await self.dispatcher.wait_for_event(event_type, attribute_filters, timeout) return await self.dispatcher.wait_for_event(
event_type, attribute_filters, timeout
)
def _setup_data_tracking(self): def _setup_data_tracking(self):
"""Set up event subscriptions to track data internally""" """Set up event subscriptions to track data internally"""
async def _update_contacts(event): async def _update_contacts(event):
#self._contacts.update(event.payload) # self._contacts.update(event.payload)
for c in event.payload.values(): for c in event.payload.values():
if c["public_key"] in self._contacts: if c["public_key"] in self._contacts:
self._contacts[c["public_key"]].update(c) self._contacts[c["public_key"]].update(c)
else: else:
self._contacts[c["public_key"]]=c self._contacts[c["public_key"]] = c
if "lastmod" in event.attributes : if "lastmod" in event.attributes:
self._lastmod = event.attributes['lastmod'] self._lastmod = event.attributes["lastmod"]
self._contacts_dirty = False self._contacts_dirty = False
async def _add_pending_contact(event): async def _add_pending_contact(event):
@ -223,61 +289,61 @@ class MeshCore:
# Getter methods for state # Getter methods for state
@property @property
def contacts(self): def contacts(self) -> Dict[str, Any]:
"""Get the current contacts""" """Get the current contacts"""
return self._contacts return self._contacts
@property @property
def contacts_dirty(self): def contacts_dirty(self) -> bool:
"""Get wether contact list is in sync""" """Get wether contact list is in sync"""
return self._contacts_dirty return self._contacts_dirty
@property @property
def auto_update_contacts(self): def auto_update_contacts(self) -> bool:
"""Get wether contact list is in sync""" """Get wether contact list is in sync"""
return self._auto_update_contacts return self._auto_update_contacts
@auto_update_contacts.setter @auto_update_contacts.setter
def auto_update_contacts(self, value): def auto_update_contacts(self, value: bool) -> None:
self._auto_update_contacts = value self._auto_update_contacts = value
@property @property
def self_info(self): def self_info(self) -> Dict[str, Any]:
"""Get device self info""" """Get device self info"""
return self._self_info return self._self_info
@property @property
def time(self): def time(self) -> int:
"""Get the current device time""" """Get the current device time"""
return self._time return self._time
@property @property
def is_connected(self): def is_connected(self) -> bool:
"""Check if the connection is active""" """Check if the connection is active"""
return self.connection_manager.is_connected return self.connection_manager.is_connected
@property @property
def default_timeout(self): def default_timeout(self) -> float:
"""Get the default timeout for commands""" """Get the default timeout for commands"""
return self.commands.default_timeout return self.commands.default_timeout
@default_timeout.setter @default_timeout.setter
def default_timeout(self, value): def default_timeout(self, value: float) -> None:
"""Set the default timeout for commands""" """Set the default timeout for commands"""
self.commands.default_timeout = value self.commands.default_timeout = value
@property @property
def pending_contacts(self): def pending_contacts(self) -> Dict[str, Any]:
"""Get pending contacts""" """Get pending contacts"""
return self._pending_contacts return self._pending_contacts
def pop_pending_contact(self, key): def pop_pending_contact(self, key: str) -> Optional[Dict[str, Any]]:
return self._pending_contacts.pop(key, None) return self._pending_contacts.pop(key, None)
def flush_pending_contacts(self): # would be interesting to have a time param def flush_pending_contacts(self) -> None: # would be interesting to have a time param
self._pending_contacts = {} self._pending_contacts = {}
def get_contact_by_name(self, name) -> Optional[Dict[str, Any]]: def get_contact_by_name(self, name: str) -> Optional[Dict[str, Any]]:
""" """
Find a contact by its name (adv_name field) Find a contact by its name (adv_name field)
@ -296,7 +362,7 @@ class MeshCore:
return None return None
def get_contact_by_key_prefix(self, prefix) -> Optional[Dict[str, Any]]: def get_contact_by_key_prefix(self, prefix: str) -> Optional[Dict[str, Any]]:
""" """
Find a contact by its public key prefix Find a contact by its public key prefix
@ -319,7 +385,7 @@ class MeshCore:
return None return None
async def start_auto_message_fetching(self): async def start_auto_message_fetching(self) -> Subscription:
""" """
Start automatically fetching messages when messages_waiting events are received. Start automatically fetching messages when messages_waiting events are received.
This will continuously check for new messages when the device indicates This will continuously check for new messages when the device indicates
@ -340,8 +406,13 @@ class MeshCore:
result = await self.commands.get_msg() result = await self.commands.get_msg()
# If we got a NO_MORE_MSGS event or an error, stop fetching # If we got a NO_MORE_MSGS event or an error, stop fetching
if result.type == EventType.NO_MORE_MSGS or result.type == EventType.ERROR: if (
logger.debug("No more messages or error occurred, stopping auto-fetch.") result.type == EventType.NO_MORE_MSGS
or result.type == EventType.ERROR
):
logger.debug(
"No more messages or error occurred, stopping auto-fetch."
)
break break
# Small delay to prevent overwhelming the device # Small delay to prevent overwhelming the device
@ -351,7 +422,9 @@ class MeshCore:
break break
# Subscribe to MESSAGES_WAITING events # Subscribe to MESSAGES_WAITING events
self._auto_fetch_subscription = self.subscribe(EventType.MESSAGES_WAITING, _handle_messages_waiting) self._auto_fetch_subscription = self.subscribe(
EventType.MESSAGES_WAITING, _handle_messages_waiting
)
# Check for any pending messages immediately # Check for any pending messages immediately
await self.commands.get_msg() await self.commands.get_msg()
@ -362,24 +435,28 @@ class MeshCore:
""" """
Stop automatically fetching messages when messages_waiting events are received. Stop automatically fetching messages when messages_waiting events are received.
""" """
if hasattr(self, '_auto_fetch_subscription') and self._auto_fetch_subscription: if hasattr(self, "_auto_fetch_subscription") and self._auto_fetch_subscription:
self.unsubscribe(self._auto_fetch_subscription) self.unsubscribe(self._auto_fetch_subscription)
self._auto_fetch_subscription = None self._auto_fetch_subscription = None
if hasattr(self, '_auto_fetch_running'): if hasattr(self, "_auto_fetch_running"):
self._auto_fetch_running = False self._auto_fetch_running = False
if hasattr(self, '_auto_fetch_task') and self._auto_fetch_task and not self._auto_fetch_task.done(): if (
hasattr(self, "_auto_fetch_task")
and self._auto_fetch_task
and not self._auto_fetch_task.done()
):
self._auto_fetch_task.cancel() self._auto_fetch_task.cancel()
try: try:
await self._auto_fetch_task # type: ignore await self._auto_fetch_task # type: ignore
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
self._auto_fetch_task = None self._auto_fetch_task = None
async def ensure_contacts(self, follow=False): async def ensure_contacts(self, follow: bool = False) -> bool:
"""Ensure contacts are fetched""" """Ensure contacts are fetched"""
if not self._contacts or (follow and self._contacts_dirty) : if not self._contacts or (follow and self._contacts_dirty):
await self.commands.get_contacts(lastmod = self._lastmod) await self.commands.get_contacts(lastmod=self._lastmod)
return True return True
return False return False

View file

@ -1,5 +1,6 @@
from enum import Enum from enum import Enum
# Packet prefixes for the protocol # Packet prefixes for the protocol
class PacketType(Enum): class PacketType(Enum):
OK = 0 OK = 0

View file

@ -1,8 +1,6 @@
import sys
import logging import logging
import asyncio
import json import json
from typing import Any, Optional, Dict from typing import Any, Dict
from .events import Event, EventType, EventDispatcher from .events import Event, EventType, EventDispatcher
from .packets import PacketType from .packets import PacketType
from cayennelpp import LppFrame, LppData from cayennelpp import LppFrame, LppData
@ -27,7 +25,7 @@ class MessageReader:
if packet_type_value == PacketType.OK.value: if packet_type_value == PacketType.OK.value:
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
if len(data) == 5: if len(data) == 5:
result["value"] = int.from_bytes(data[1:5], byteorder='little') result["value"] = int.from_bytes(data[1:5], byteorder="little")
# Dispatch event for the OK response # Dispatch event for the OK response
await self.dispatcher.dispatch(Event(EventType.OK, result)) await self.dispatcher.dispatch(Event(EventType.OK, result))
@ -42,11 +40,13 @@ class MessageReader:
await self.dispatcher.dispatch(Event(EventType.ERROR, result)) await self.dispatcher.dispatch(Event(EventType.ERROR, result))
elif packet_type_value == PacketType.CONTACT_START.value: elif packet_type_value == PacketType.CONTACT_START.value:
self.contact_nb = int.from_bytes(data[1:5], byteorder='little') self.contact_nb = int.from_bytes(data[1:5], byteorder="little")
self.contacts = {} self.contacts = {}
elif packet_type_value == PacketType.CONTACT.value or\ elif (
packet_type_value == PacketType.PUSH_CODE_NEW_ADVERT.value: packet_type_value == PacketType.CONTACT.value
or packet_type_value == PacketType.PUSH_CODE_NEW_ADVERT.value
):
c = {} c = {}
c["public_key"] = data[1:33].hex() c["public_key"] = data[1:33].hex()
c["type"] = data[33] c["type"] = data[33]
@ -55,24 +55,30 @@ class MessageReader:
plen = int.from_bytes(data[35:36], signed=True) plen = int.from_bytes(data[35:36], signed=True)
if plen == -1: if plen == -1:
plen = 0 plen = 0
c["out_path"] = data[36:36+plen].hex() c["out_path"] = data[36 : 36 + plen].hex()
c["adv_name"] = data[100:132].decode('utf-8', 'ignore').replace("\0","") c["adv_name"] = data[100:132].decode("utf-8", "ignore").replace("\0", "")
c["last_advert"] = int.from_bytes(data[132:136], byteorder='little') c["last_advert"] = int.from_bytes(data[132:136], byteorder="little")
c["adv_lat"] = int.from_bytes(data[136:140], byteorder='little',signed=True)/1e6 c["adv_lat"] = (
c["adv_lon"] = int.from_bytes(data[140:144], byteorder='little',signed=True)/1e6 int.from_bytes(data[136:140], byteorder="little", signed=True) / 1e6
c["lastmod"] = int.from_bytes(data[144:148], byteorder='little') )
c["adv_lon"] = (
int.from_bytes(data[140:144], byteorder="little", signed=True) / 1e6
)
c["lastmod"] = int.from_bytes(data[144:148], byteorder="little")
if packet_type_value == PacketType.PUSH_CODE_NEW_ADVERT.value : if packet_type_value == PacketType.PUSH_CODE_NEW_ADVERT.value:
await self.dispatcher.dispatch(Event(EventType.NEW_CONTACT, c)) await self.dispatcher.dispatch(Event(EventType.NEW_CONTACT, c))
else: else:
self.contacts[c["public_key"]] = c self.contacts[c["public_key"]] = c
elif packet_type_value == PacketType.CONTACT_END.value: elif packet_type_value == PacketType.CONTACT_END.value:
lastmod = int.from_bytes(data[1:5], byteorder='little') lastmod = int.from_bytes(data[1:5], byteorder="little")
attributes = { attributes = {
"lastmod": lastmod, "lastmod": lastmod,
} }
await self.dispatcher.dispatch(Event(EventType.CONTACTS, self.contacts, attributes)) await self.dispatcher.dispatch(
Event(EventType.CONTACTS, self.contacts, attributes)
)
elif packet_type_value == PacketType.SELF_INFO.value: elif packet_type_value == PacketType.SELF_INFO.value:
self_info = {} self_info = {}
@ -80,29 +86,37 @@ class MessageReader:
self_info["tx_power"] = data[2] self_info["tx_power"] = data[2]
self_info["max_tx_power"] = data[3] self_info["max_tx_power"] = data[3]
self_info["public_key"] = data[4:36].hex() self_info["public_key"] = data[4:36].hex()
self_info["adv_lat"] = int.from_bytes(data[36:40], byteorder='little', signed=True)/1e6 self_info["adv_lat"] = (
self_info["adv_lon"] = int.from_bytes(data[40:44], byteorder='little', signed=True)/1e6 int.from_bytes(data[36:40], byteorder="little", signed=True) / 1e6
)
self_info["adv_lon"] = (
int.from_bytes(data[40:44], byteorder="little", signed=True) / 1e6
)
self_info["adv_loc_policy"] = data[45] self_info["adv_loc_policy"] = data[45]
self_info["telemetry_mode_env"] = (data[46] >> 4) & 0b11 self_info["telemetry_mode_env"] = (data[46] >> 4) & 0b11
self_info["telemetry_mode_loc"] = (data[46] >> 2) & 0b11 self_info["telemetry_mode_loc"] = (data[46] >> 2) & 0b11
self_info["telemetry_mode_base"] = (data[46]) & 0b11 self_info["telemetry_mode_base"] = (data[46]) & 0b11
self_info["manual_add_contacts"] = data[47] > 0 self_info["manual_add_contacts"] = data[47] > 0
self_info["radio_freq"] = int.from_bytes(data[48:52], byteorder='little') / 1000 self_info["radio_freq"] = (
self_info["radio_bw"] = int.from_bytes(data[52:56], byteorder='little') / 1000 int.from_bytes(data[48:52], byteorder="little") / 1000
)
self_info["radio_bw"] = (
int.from_bytes(data[52:56], byteorder="little") / 1000
)
self_info["radio_sf"] = data[56] self_info["radio_sf"] = data[56]
self_info["radio_cr"] = data[57] self_info["radio_cr"] = data[57]
self_info["name"] = data[58:].decode('utf-8', 'ignore') self_info["name"] = data[58:].decode("utf-8", "ignore")
await self.dispatcher.dispatch(Event(EventType.SELF_INFO, self_info)) await self.dispatcher.dispatch(Event(EventType.SELF_INFO, self_info))
elif packet_type_value == PacketType.MSG_SENT.value: elif packet_type_value == PacketType.MSG_SENT.value:
res = {} res = {}
res["type"] = data[1] res["type"] = data[1]
res["expected_ack"] = bytes(data[2:6]) res["expected_ack"] = bytes(data[2:6])
res["suggested_timeout"] = int.from_bytes(data[6:10], byteorder='little') res["suggested_timeout"] = int.from_bytes(data[6:10], byteorder="little")
attributes = { attributes = {
"type": res["type"], "type": res["type"],
"expected_ack": res["expected_ack"].hex() "expected_ack": res["expected_ack"].hex(),
} }
await self.dispatcher.dispatch(Event(EventType.MSG_SENT, res, attributes)) await self.dispatcher.dispatch(Event(EventType.MSG_SENT, res, attributes))
@ -113,16 +127,16 @@ class MessageReader:
res["pubkey_prefix"] = data[1:7].hex() res["pubkey_prefix"] = data[1:7].hex()
res["path_len"] = data[7] res["path_len"] = data[7]
res["txt_type"] = data[8] res["txt_type"] = data[8]
res["sender_timestamp"] = int.from_bytes(data[9:13], byteorder='little') res["sender_timestamp"] = int.from_bytes(data[9:13], byteorder="little")
if data[8] == 2: if data[8] == 2:
res["signature"] = data[13:17].hex() res["signature"] = data[13:17].hex()
res["text"] = data[17:].decode('utf-8', 'ignore') res["text"] = data[17:].decode("utf-8", "ignore")
else: else:
res["text"] = data[13:].decode('utf-8', 'ignore') res["text"] = data[13:].decode("utf-8", "ignore")
attributes = { attributes = {
"pubkey_prefix": res["pubkey_prefix"], "pubkey_prefix": res["pubkey_prefix"],
"txt_type": res["txt_type"] "txt_type": res["txt_type"],
} }
evt_type = EventType.CONTACT_MSG_RECV evt_type = EventType.CONTACT_MSG_RECV
@ -132,23 +146,25 @@ class MessageReader:
elif packet_type_value == 16: # A reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) elif packet_type_value == 16: # A reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3)
res = {} res = {}
res["type"] = "PRIV" res["type"] = "PRIV"
res["SNR"] = int.from_bytes(data[1:2], byteorder='little', signed=True) / 4 res["SNR"] = int.from_bytes(data[1:2], byteorder="little", signed=True) / 4
res["pubkey_prefix"] = data[4:10].hex() res["pubkey_prefix"] = data[4:10].hex()
res["path_len"] = data[10] res["path_len"] = data[10]
res["txt_type"] = data[11] res["txt_type"] = data[11]
res["sender_timestamp"] = int.from_bytes(data[12:16], byteorder='little') res["sender_timestamp"] = int.from_bytes(data[12:16], byteorder="little")
if data[11] == 2: if data[11] == 2:
res["signature"] = data[16:20].hex() res["signature"] = data[16:20].hex()
res["text"] = data[20:].decode('utf-8', 'ignore') res["text"] = data[20:].decode("utf-8", "ignore")
else: else:
res["text"] = data[16:].decode('utf-8', 'ignore') res["text"] = data[16:].decode("utf-8", "ignore")
attributes = { attributes = {
"pubkey_prefix": res["pubkey_prefix"], "pubkey_prefix": res["pubkey_prefix"],
"txt_type": res["txt_type"] "txt_type": res["txt_type"],
} }
await self.dispatcher.dispatch(Event(EventType.CONTACT_MSG_RECV, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.CONTACT_MSG_RECV, res, attributes)
)
elif packet_type_value == PacketType.CHANNEL_MSG_RECV.value: elif packet_type_value == PacketType.CHANNEL_MSG_RECV.value:
res = {} res = {}
@ -156,35 +172,39 @@ class MessageReader:
res["channel_idx"] = data[1] res["channel_idx"] = data[1]
res["path_len"] = data[2] res["path_len"] = data[2]
res["txt_type"] = data[3] res["txt_type"] = data[3]
res["sender_timestamp"] = int.from_bytes(data[4:8], byteorder='little') res["sender_timestamp"] = int.from_bytes(data[4:8], byteorder="little")
res["text"] = data[8:].decode('utf-8', 'ignore') res["text"] = data[8:].decode("utf-8", "ignore")
attributes = { attributes = {
"channel_idx": res["channel_idx"], "channel_idx": res["channel_idx"],
"txt_type": res["txt_type"] "txt_type": res["txt_type"],
} }
await self.dispatcher.dispatch(Event(EventType.CHANNEL_MSG_RECV, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.CHANNEL_MSG_RECV, res, attributes)
)
elif packet_type_value == 17: # A reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3) elif packet_type_value == 17: # A reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3)
res = {} res = {}
res["type"] = "CHAN" res["type"] = "CHAN"
res["SNR"] = int.from_bytes(data[1:2], byteorder='little', signed=True) / 4 res["SNR"] = int.from_bytes(data[1:2], byteorder="little", signed=True) / 4
res["channel_idx"] = data[4] res["channel_idx"] = data[4]
res["path_len"] = data[5] res["path_len"] = data[5]
res["txt_type"] = data[6] res["txt_type"] = data[6]
res["sender_timestamp"] = int.from_bytes(data[7:11], byteorder='little') res["sender_timestamp"] = int.from_bytes(data[7:11], byteorder="little")
res["text"] = data[11:].decode('utf-8', 'ignore') res["text"] = data[11:].decode("utf-8", "ignore")
attributes = { attributes = {
"channel_idx": res["channel_idx"], "channel_idx": res["channel_idx"],
"txt_type": res["txt_type"] "txt_type": res["txt_type"],
} }
await self.dispatcher.dispatch(Event(EventType.CHANNEL_MSG_RECV, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.CHANNEL_MSG_RECV, res, attributes)
)
elif packet_type_value == PacketType.CURRENT_TIME.value: elif packet_type_value == PacketType.CURRENT_TIME.value:
time_value = int.from_bytes(data[1:5], byteorder='little') time_value = int.from_bytes(data[1:5], byteorder="little")
result = {"time": time_value} result = {"time": time_value}
await self.dispatcher.dispatch(Event(EventType.CURRENT_TIME, result)) await self.dispatcher.dispatch(Event(EventType.CURRENT_TIME, result))
@ -198,11 +218,11 @@ class MessageReader:
await self.dispatcher.dispatch(Event(EventType.CONTACT_URI, result)) await self.dispatcher.dispatch(Event(EventType.CONTACT_URI, result))
elif packet_type_value == PacketType.BATTERY.value: elif packet_type_value == PacketType.BATTERY.value:
battery_level = int.from_bytes(data[1:3], byteorder='little') battery_level = int.from_bytes(data[1:3], byteorder="little")
result = {"level": battery_level} result = {"level": battery_level}
if len(data) > 3 : # has storage info as well if len(data) > 3: # has storage info as well
result["used_kb"] = int.from_bytes(data[3:7], byteorder='little') result["used_kb"] = int.from_bytes(data[3:7], byteorder="little")
result["total_kb"] = int.from_bytes(data[7:11], byteorder='little') result["total_kb"] = int.from_bytes(data[7:11], byteorder="little")
await self.dispatcher.dispatch(Event(EventType.BATTERY, result)) await self.dispatcher.dispatch(Event(EventType.BATTERY, result))
elif packet_type_value == PacketType.DEVICE_INFO.value: elif packet_type_value == PacketType.DEVICE_INFO.value:
@ -211,19 +231,19 @@ class MessageReader:
if data[1] >= 3: if data[1] >= 3:
res["max_contacts"] = data[2] * 2 res["max_contacts"] = data[2] * 2
res["max_channels"] = data[3] res["max_channels"] = data[3]
res["ble_pin"] = int.from_bytes(data[4:8], byteorder='little') res["ble_pin"] = int.from_bytes(data[4:8], byteorder="little")
res["fw_build"] = data[8:20].decode('utf-8', 'ignore').replace("\0","") res["fw_build"] = data[8:20].decode("utf-8", "ignore").replace("\0", "")
res["model"] = data[20:60].decode('utf-8', 'ignore').replace("\0","") res["model"] = data[20:60].decode("utf-8", "ignore").replace("\0", "")
res["ver"] = data[60:80].decode('utf-8', 'ignore').replace("\0","") res["ver"] = data[60:80].decode("utf-8", "ignore").replace("\0", "")
await self.dispatcher.dispatch(Event(EventType.DEVICE_INFO, res)) await self.dispatcher.dispatch(Event(EventType.DEVICE_INFO, res))
elif packet_type_value == PacketType.CUSTOM_VARS.value: elif packet_type_value == PacketType.CUSTOM_VARS.value:
logger.debug(f"received custom vars response: {data.hex()}") logger.debug(f"received custom vars response: {data.hex()}")
res = {} res = {}
rawdata = data[1:].decode('utf-8', 'ignore') rawdata = data[1:].decode("utf-8", "ignore")
if not rawdata == "" : if not rawdata == "":
pairs = rawdata.split(",") pairs = rawdata.split(",")
for p in pairs : for p in pairs:
psplit = p.split(":") psplit = p.split(":")
res[psplit[0]] = psplit[1] res[psplit[0]] = psplit[1]
logger.debug(f"got custom vars : {res}") logger.debug(f"got custom vars : {res}")
@ -238,9 +258,9 @@ class MessageReader:
name_bytes = data[2:34] name_bytes = data[2:34]
null_pos = name_bytes.find(0) null_pos = name_bytes.find(0)
if null_pos >= 0: if null_pos >= 0:
res["channel_name"] = name_bytes[:null_pos].decode('utf-8', 'ignore') res["channel_name"] = name_bytes[:null_pos].decode("utf-8", "ignore")
else: else:
res["channel_name"] = name_bytes.decode('utf-8', 'ignore') res["channel_name"] = name_bytes.decode("utf-8", "ignore")
res["channel_secret"] = data[34:50] res["channel_secret"] = data[34:50]
await self.dispatcher.dispatch(Event(EventType.CHANNEL_INFO, res, res)) await self.dispatcher.dispatch(Event(EventType.CHANNEL_INFO, res, res))
@ -265,9 +285,7 @@ class MessageReader:
if len(data) >= 5: if len(data) >= 5:
ack_data["code"] = bytes(data[1:5]).hex() ack_data["code"] = bytes(data[1:5]).hex()
attributes = { attributes = {"code": ack_data.get("code", "")}
"code": ack_data.get("code", "")
}
await self.dispatcher.dispatch(Event(EventType.ACK, ack_data, attributes)) await self.dispatcher.dispatch(Event(EventType.ACK, ack_data, attributes))
@ -293,11 +311,11 @@ class MessageReader:
if len(data) > 7: if len(data) > 7:
res["pubkey_prefix"] = data[2:8].hex() res["pubkey_prefix"] = data[2:8].hex()
attributes = { attributes = {"pubkey_prefix": res.get("pubkey_prefix")}
"pubkey_prefix": res.get("pubkey_prefix")
}
await self.dispatcher.dispatch(Event(EventType.LOGIN_SUCCESS, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.LOGIN_SUCCESS, res, attributes)
)
elif packet_type_value == PacketType.LOGIN_FAILED.value: elif packet_type_value == PacketType.LOGIN_FAILED.value:
res = {} res = {}
@ -305,32 +323,42 @@ class MessageReader:
if len(data) > 7: if len(data) > 7:
res["pubkey_prefix"] = data[2:8].hex() res["pubkey_prefix"] = data[2:8].hex()
attributes = { attributes = {"pubkey_prefix": res.get("pubkey_prefix")}
"pubkey_prefix": res.get("pubkey_prefix")
}
await self.dispatcher.dispatch(Event(EventType.LOGIN_FAILED, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.LOGIN_FAILED, res, attributes)
)
elif packet_type_value == PacketType.STATUS_RESPONSE.value: elif packet_type_value == PacketType.STATUS_RESPONSE.value:
res = {} res = {}
res["pubkey_pre"] = data[2:8].hex() res["pubkey_pre"] = data[2:8].hex()
res["bat"] = int.from_bytes(data[8:10], byteorder='little') res["bat"] = int.from_bytes(data[8:10], byteorder="little")
res["tx_queue_len"] = int.from_bytes(data[10:12], byteorder='little') res["tx_queue_len"] = int.from_bytes(data[10:12], byteorder="little")
res["noise_floor"] = int.from_bytes(data[12:14], byteorder='little', signed=True) res["noise_floor"] = int.from_bytes(
res["last_rssi"] = int.from_bytes(data[14:16], byteorder='little', signed=True) data[12:14], byteorder="little", signed=True
res["nb_recv"] = int.from_bytes(data[16:20], byteorder='little', signed=False) )
res["nb_sent"] = int.from_bytes(data[20:24], byteorder='little', signed=False) res["last_rssi"] = int.from_bytes(
res["airtime"] = int.from_bytes(data[24:28], byteorder='little') data[14:16], byteorder="little", signed=True
res["uptime"] = int.from_bytes(data[28:32], byteorder='little') )
res["sent_flood"] = int.from_bytes(data[32:36], byteorder='little') res["nb_recv"] = int.from_bytes(
res["sent_direct"] = int.from_bytes(data[36:40], byteorder='little') data[16:20], byteorder="little", signed=False
res["recv_flood"] = int.from_bytes(data[40:44], byteorder='little') )
res["recv_direct"] = int.from_bytes(data[44:48], byteorder='little') res["nb_sent"] = int.from_bytes(
res["full_evts"] = int.from_bytes(data[48:50], byteorder='little') data[20:24], byteorder="little", signed=False
res["last_snr"] = int.from_bytes(data[50:52], byteorder='little', signed=True) / 4 )
res["direct_dups"] = int.from_bytes(data[52:54], byteorder='little') res["airtime"] = int.from_bytes(data[24:28], byteorder="little")
res["flood_dups"] = int.from_bytes(data[54:56], byteorder='little') res["uptime"] = int.from_bytes(data[28:32], byteorder="little")
res["rx_airtime"] = int.from_bytes(data[56:60], byteorder='little') res["sent_flood"] = int.from_bytes(data[32:36], byteorder="little")
res["sent_direct"] = int.from_bytes(data[36:40], byteorder="little")
res["recv_flood"] = int.from_bytes(data[40:44], byteorder="little")
res["recv_direct"] = int.from_bytes(data[44:48], byteorder="little")
res["full_evts"] = int.from_bytes(data[48:50], byteorder="little")
res["last_snr"] = (
int.from_bytes(data[50:52], byteorder="little", signed=True) / 4
)
res["direct_dups"] = int.from_bytes(data[52:54], byteorder="little")
res["flood_dups"] = int.from_bytes(data[54:56], byteorder="little")
res["rx_airtime"] = int.from_bytes(data[56:60], byteorder="little")
data_hex = data[8:].hex() data_hex = data[8:].hex()
logger.debug(f"Status response: {data_hex}") logger.debug(f"Status response: {data_hex}")
@ -338,15 +366,15 @@ class MessageReader:
attributes = { attributes = {
"pubkey_prefix": res["pubkey_pre"], "pubkey_prefix": res["pubkey_pre"],
} }
await self.dispatcher.dispatch(Event(EventType.STATUS_RESPONSE, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.STATUS_RESPONSE, res, attributes)
)
elif packet_type_value == PacketType.LOG_DATA.value: elif packet_type_value == PacketType.LOG_DATA.value:
logger.debug(f"Received RF log data: {data.hex()}") logger.debug(f"Received RF log data: {data.hex()}")
# Parse as raw RX data # Parse as raw RX data
log_data: Dict[str, Any] = { log_data: Dict[str, Any] = {"raw_hex": data[1:].hex()}
"raw_hex": data[1:].hex()
}
# First byte is SNR (signed byte, multiplied by 4) # First byte is SNR (signed byte, multiplied by 4)
if len(data) > 1: if len(data) > 1:
@ -372,7 +400,9 @@ class MessageReader:
} }
# Dispatch as RF log data # Dispatch as RF log data
await self.dispatcher.dispatch(Event(EventType.RX_LOG_DATA, log_data, attributes)) await self.dispatcher.dispatch(
Event(EventType.RX_LOG_DATA, log_data, attributes)
)
elif packet_type_value == PacketType.TRACE_DATA.value: elif packet_type_value == PacketType.TRACE_DATA.value:
logger.debug(f"Received trace data: {data.hex()}") logger.debug(f"Received trace data: {data.hex()}")
@ -381,11 +411,10 @@ class MessageReader:
# According to the source, format is: # According to the source, format is:
# 0x89, reserved(0), path_len, flags, tag(4), auth(4), path_hashes[], path_snrs[], final_snr # 0x89, reserved(0), path_len, flags, tag(4), auth(4), path_hashes[], path_snrs[], final_snr
reserved = data[1]
path_len = data[2] path_len = data[2]
flags = data[3] flags = data[3]
tag = int.from_bytes(data[4:8], byteorder='little') tag = int.from_bytes(data[4:8], byteorder="little")
auth_code = int.from_bytes(data[8:12], byteorder='little') auth_code = int.from_bytes(data[8:12], byteorder="little")
# Initialize result # Initialize result
res["tag"] = tag res["tag"] = tag
@ -396,22 +425,27 @@ class MessageReader:
# Process path as array of objects with hash and SNR # Process path as array of objects with hash and SNR
path_nodes = [] path_nodes = []
if path_len > 0 and len(data) >= 12 + path_len*2 + 1: if path_len > 0 and len(data) >= 12 + path_len * 2 + 1:
# Extract path with hash and SNR pairs # Extract path with hash and SNR pairs
for i in range(path_len): for i in range(path_len):
node = { node = {
"hash": f"{data[12+i]:02x}", "hash": f"{data[12+i]:02x}",
# SNR is stored as a signed byte representing SNR * 4 # SNR is stored as a signed byte representing SNR * 4
"snr": (data[12+path_len+i] if data[12+path_len+i] < 128 else data[12+path_len+i] - 256) / 4.0 "snr": (
data[12 + path_len + i]
if data[12 + path_len + i] < 128
else data[12 + path_len + i] - 256
)
/ 4.0,
} }
path_nodes.append(node) path_nodes.append(node)
# Add the final node (our device) with its SNR # Add the final node (our device) with its SNR
final_snr_byte = data[12+path_len*2] final_snr_byte = data[12 + path_len * 2]
final_snr = (final_snr_byte if final_snr_byte < 128 else final_snr_byte - 256) / 4.0 final_snr = (
path_nodes.append({ final_snr_byte if final_snr_byte < 128 else final_snr_byte - 256
"snr": final_snr ) / 4.0
}) path_nodes.append({"snr": final_snr})
res["path"] = path_nodes res["path"] = path_nodes
@ -439,15 +473,19 @@ class MessageReader:
lpp_data_list.append(lppdata) lpp_data_list.append(lppdata)
i = i + len(lppdata) i = i + len(lppdata)
lpp = json.loads(json.dumps(LppFrame(lpp_data_list), default=lpp_json_encoder)) lpp = json.loads(
json.dumps(LppFrame(lpp_data_list), default=lpp_json_encoder)
)
res["lpp"] = lpp res["lpp"] = lpp
attributes = { attributes = {
"raw" : buf.hex(), "raw": buf.hex(),
} }
await self.dispatcher.dispatch(Event(EventType.TELEMETRY_RESPONSE, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.TELEMETRY_RESPONSE, res, attributes)
)
elif packet_type_value == PacketType.BINARY_RESPONSE.value: elif packet_type_value == PacketType.BINARY_RESPONSE.value:
logger.debug(f"Received binary data: {data.hex()}") logger.debug(f"Received binary data: {data.hex()}")
@ -456,11 +494,11 @@ class MessageReader:
res["tag"] = data[2:6].hex() res["tag"] = data[2:6].hex()
res["data"] = data[6:].hex() res["data"] = data[6:].hex()
attributes = { attributes = {"tag": res["tag"]}
"tag" : res["tag"]
}
await self.dispatcher.dispatch(Event(EventType.BINARY_RESPONSE, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.BINARY_RESPONSE, res, attributes)
)
elif packet_type_value == PacketType.PATH_DISCOVERY_RESPONSE.value: elif packet_type_value == PacketType.PATH_DISCOVERY_RESPONSE.value:
logger.debug(f"Received path discovery response: {data.hex()}") logger.debug(f"Received path discovery response: {data.hex()}")
@ -468,18 +506,17 @@ class MessageReader:
res["pubkey_pre"] = data[2:8].hex() res["pubkey_pre"] = data[2:8].hex()
opl = data[8] opl = data[8]
res["out_path_len"] = opl res["out_path_len"] = opl
res["out_path"] = data[9:9+opl].hex() res["out_path"] = data[9 : 9 + opl].hex()
ipl = data[9+opl] ipl = data[9 + opl]
res["in_path_len"] = ipl res["in_path_len"] = ipl
res["in_path"] = data[10+opl:10+opl+ipl].hex() res["in_path"] = data[10 + opl : 10 + opl + ipl].hex()
attributes = { attributes = {"pubkey_pre": res["pubkey_pre"]}
"pubkey_pre" : res["pubkey_pre"]
}
await self.dispatcher.dispatch(Event(EventType.PATH_RESPONSE, res, attributes)) await self.dispatcher.dispatch(
Event(EventType.PATH_RESPONSE, res, attributes)
)
else: else:
logger.debug(f"Unhandled data received {data}") logger.debug(f"Unhandled data received {data}")
logger.debug(f"Unhandled packet type: {packet_type_value}") logger.debug(f"Unhandled packet type: {packet_type_value}")

View file

@ -1,6 +1,7 @@
""" """
mccli.py : CLI interface to MeschCore BLE companion app mccli.py : CLI interface to MeschCore BLE companion app
""" """
import asyncio import asyncio
import logging import logging
import serial_asyncio import serial_asyncio
@ -8,6 +9,7 @@ import serial_asyncio
# Get logger # Get logger
logger = logging.getLogger("meshcore") logger = logging.getLogger("meshcore")
class SerialConnection: class SerialConnection:
def __init__(self, port, baudrate, cx_dly=0.2): def __init__(self, port, baudrate, cx_dly=0.2):
self.port = port self.port = port
@ -27,23 +29,28 @@ class SerialConnection:
def connection_made(self, transport): def connection_made(self, transport):
self.cx.transport = transport self.cx.transport = transport
logger.debug('port opened') logger.debug("port opened")
if isinstance(transport, serial_asyncio.SerialTransport) and transport.serial: if (
transport.serial.rts = False # You can manipulate Serial object via transport isinstance(transport, serial_asyncio.SerialTransport)
and transport.serial
):
transport.serial.rts = (
False # You can manipulate Serial object via transport
)
def data_received(self, data): def data_received(self, data):
self.cx.handle_rx(data) self.cx.handle_rx(data)
def connection_lost(self, exc): def connection_lost(self, exc):
logger.debug('Serial port closed') logger.debug("Serial port closed")
if self.cx._disconnect_callback: if self.cx._disconnect_callback:
asyncio.create_task(self.cx._disconnect_callback("serial_disconnect")) asyncio.create_task(self.cx._disconnect_callback("serial_disconnect"))
def pause_writing(self): def pause_writing(self):
logger.debug('pause writing') logger.debug("pause writing")
def resume_writing(self): def resume_writing(self):
logger.debug('resume writing') logger.debug("resume writing")
async def connect(self): async def connect(self):
""" """
@ -51,39 +58,42 @@ class SerialConnection:
""" """
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await serial_asyncio.create_serial_connection( await serial_asyncio.create_serial_connection(
loop, lambda: self.MCSerialClientProtocol(self), loop,
self.port, baudrate=self.baudrate) lambda: self.MCSerialClientProtocol(self),
self.port,
baudrate=self.baudrate,
)
await asyncio.sleep(self.cx_dly) # wait for cx to establish await asyncio.sleep(self.cx_dly) # wait for cx to establish
logger.info("Serial Connection started") logger.info("Serial Connection started")
return self.port return self.port
def set_reader(self, reader) : def set_reader(self, reader):
self.reader = reader self.reader = reader
def handle_rx(self, data: bytearray): def handle_rx(self, data: bytearray):
headerlen = len(self.header) headerlen = len(self.header)
framelen = len(self.inframe) framelen = len(self.inframe)
if not self.frame_started : # wait start of frame if not self.frame_started: # wait start of frame
if len(data) >= 3 - headerlen: if len(data) >= 3 - headerlen:
self.header = self.header + data[:3-headerlen] self.header = self.header + data[: 3 - headerlen]
self.frame_started = True self.frame_started = True
self.frame_size = int.from_bytes(self.header[1:], byteorder='little') self.frame_size = int.from_bytes(self.header[1:], byteorder="little")
self.handle_rx(data[3-headerlen:]) self.handle_rx(data[3 - headerlen :])
else: else:
self.header = self.header + data self.header = self.header + data
else: else:
if framelen + len(data) < self.frame_size: if framelen + len(data) < self.frame_size:
self.inframe = self.inframe + data self.inframe = self.inframe + data
else: else:
self.inframe = self.inframe + data[:self.frame_size-framelen] self.inframe = self.inframe + data[: self.frame_size - framelen]
if not self.reader is None: if self.reader is not None:
asyncio.create_task(self.reader.handle_rx(self.inframe)) asyncio.create_task(self.reader.handle_rx(self.inframe))
self.frame_started = False self.frame_started = False
self.header = b"" self.header = b""
self.inframe = b"" self.inframe = b""
if framelen + len(data) > self.frame_size: if framelen + len(data) > self.frame_size:
self.handle_rx(data[self.frame_size-framelen:]) self.handle_rx(data[self.frame_size - framelen :])
async def send(self, data): async def send(self, data):
if not self.transport: if not self.transport:

View file

@ -1,6 +1,7 @@
""" """
mccli.py : CLI interface to MeschCore BLE companion app mccli.py : CLI interface to MeschCore BLE companion app
""" """
import asyncio import asyncio
import logging import logging
@ -10,6 +11,7 @@ logger = logging.getLogger("meshcore")
# TCP disconnect detection threshold # TCP disconnect detection threshold
TCP_DISCONNECT_THRESHOLD = 5 TCP_DISCONNECT_THRESHOLD = 5
class TCPConnection: class TCPConnection:
def __init__(self, host, port): def __init__(self, host, port):
self.host = host self.host = host
@ -32,18 +34,18 @@ class TCPConnection:
# Reset counters on new connection # Reset counters on new connection
self.cx._send_count = 0 self.cx._send_count = 0
self.cx._receive_count = 0 self.cx._receive_count = 0
logger.debug('connection established') logger.debug("connection established")
def data_received(self, data): def data_received(self, data):
logger.debug('data received') logger.debug("data received")
self.cx._receive_count += 1 self.cx._receive_count += 1
self.cx.handle_rx(data) self.cx.handle_rx(data)
def error_received(self, exc): def error_received(self, exc):
logger.error(f'Error received: {exc}') logger.error(f"Error received: {exc}")
def connection_lost(self, exc): def connection_lost(self, exc):
logger.debug('TCP server closed the connection') logger.debug("TCP server closed the connection")
if self.cx._disconnect_callback: if self.cx._disconnect_callback:
asyncio.create_task(self.cx._disconnect_callback("tcp_disconnect")) asyncio.create_task(self.cx._disconnect_callback("tcp_disconnect"))
@ -53,8 +55,8 @@ class TCPConnection:
""" """
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.create_connection( await loop.create_connection(
lambda: self.MCClientProtocol(self), lambda: self.MCClientProtocol(self), self.host, self.port
self.host, self.port) )
logger.info("TCP Connection started") logger.info("TCP Connection started")
future = asyncio.Future() future = asyncio.Future()
@ -62,32 +64,32 @@ class TCPConnection:
return future return future
def set_reader(self, reader) : def set_reader(self, reader):
self.reader = reader self.reader = reader
def handle_rx(self, data: bytearray): def handle_rx(self, data: bytearray):
headerlen = len(self.header) headerlen = len(self.header)
framelen = len(self.inframe) framelen = len(self.inframe)
if not self.frame_started : # wait start of frame if not self.frame_started: # wait start of frame
if len(data) >= 3 - headerlen: if len(data) >= 3 - headerlen:
self.header = self.header + data[:3-headerlen] self.header = self.header + data[: 3 - headerlen]
self.frame_started = True self.frame_started = True
self.frame_size = int.from_bytes(self.header[1:], byteorder='little') self.frame_size = int.from_bytes(self.header[1:], byteorder="little")
self.handle_rx(data[3-headerlen:]) self.handle_rx(data[3 - headerlen :])
else: else:
self.header = self.header + data self.header = self.header + data
else: else:
if framelen + len(data) < self.frame_size: if framelen + len(data) < self.frame_size:
self.inframe = self.inframe + data self.inframe = self.inframe + data
else: else:
self.inframe = self.inframe + data[:self.frame_size-framelen] self.inframe = self.inframe + data[: self.frame_size - framelen]
if not self.reader is None: if self.reader is not None:
asyncio.create_task(self.reader.handle_rx(self.inframe)) asyncio.create_task(self.reader.handle_rx(self.inframe))
self.frame_started = False self.frame_started = False
self.header = b"" self.header = b""
self.inframe = b"" self.inframe = b""
if framelen + len(data) > self.frame_size: if framelen + len(data) > self.frame_size:
self.handle_rx(data[self.frame_size-framelen:]) self.handle_rx(data[self.frame_size - framelen :])
async def send(self, data): async def send(self, data):
if not self.transport: if not self.transport:
@ -100,7 +102,9 @@ class TCPConnection:
# Check if we've sent packets without any responses # Check if we've sent packets without any responses
if self._send_count - self._receive_count >= TCP_DISCONNECT_THRESHOLD: if self._send_count - self._receive_count >= TCP_DISCONNECT_THRESHOLD:
logger.debug(f"TCP disconnect detected: sent {self._send_count}, received {self._receive_count}") logger.debug(
f"TCP disconnect detected: sent {self._send_count}, received {self._receive_count}"
)
if self._disconnect_callback: if self._disconnect_callback:
await self._disconnect_callback("tcp_no_response") await self._disconnect_callback("tcp_no_response")
return return

View file

@ -2,10 +2,15 @@ import asyncio
import unittest import unittest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from meshcore.ble_cx import BLEConnection, UART_SERVICE_UUID, UART_TX_CHAR_UUID, UART_RX_CHAR_UUID from meshcore.ble_cx import (
BLEConnection,
UART_TX_CHAR_UUID,
UART_RX_CHAR_UUID,
)
class TestBLEConnection(unittest.TestCase): class TestBLEConnection(unittest.TestCase):
@patch('meshcore.ble_cx.BleakClient') @patch("meshcore.ble_cx.BleakClient")
def test_ble_connection_and_disconnection(self, mock_bleak_client): def test_ble_connection_and_disconnection(self, mock_bleak_client):
""" """
Tests the BLEConnection class for connecting and disconnecting from a BLE device. Tests the BLEConnection class for connecting and disconnecting from a BLE device.
@ -23,10 +28,12 @@ class TestBLEConnection(unittest.TestCase):
# Assert # Assert
mock_client_instance.connect.assert_called_once() mock_client_instance.connect.assert_called_once()
mock_client_instance.start_notify.assert_called_once_with(UART_TX_CHAR_UUID, ble_conn.handle_rx) mock_client_instance.start_notify.assert_called_once_with(
UART_TX_CHAR_UUID, ble_conn.handle_rx
)
mock_client_instance.disconnect.assert_called_once() mock_client_instance.disconnect.assert_called_once()
@patch('meshcore.ble_cx.BleakClient') @patch("meshcore.ble_cx.BleakClient")
def test_send_data(self, mock_bleak_client): def test_send_data(self, mock_bleak_client):
""" """
Tests the send method of the BLEConnection class. Tests the send method of the BLEConnection class.
@ -44,7 +51,9 @@ class TestBLEConnection(unittest.TestCase):
asyncio.run(ble_conn.send(data_to_send)) asyncio.run(ble_conn.send(data_to_send))
# Assert # Assert
ble_conn.rx_char.write_gatt_char.assert_called_once_with(ble_conn.rx_char, data_to_send, response=False) ble_conn.rx_char.write_gatt_char.assert_called_once_with(
ble_conn.rx_char, data_to_send, response=False
)
def _get_mock_bleak_client(self): def _get_mock_bleak_client(self):
""" """
@ -67,5 +76,6 @@ class TestBLEConnection(unittest.TestCase):
return mock_client return mock_client
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1,11 +1,12 @@
import pytest import pytest
import asyncio import asyncio
from unittest.mock import MagicMock, patch, AsyncMock from unittest.mock import MagicMock, AsyncMock
from meshcore.commands import CommandHandler from meshcore.commands import CommandHandler
from meshcore.events import EventType, Event from meshcore.events import EventType, Event
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
# Fixtures # Fixtures
@pytest.fixture @pytest.fixture
def mock_connection(): def mock_connection():
@ -13,6 +14,7 @@ def mock_connection():
connection.send = AsyncMock() connection.send = AsyncMock()
return connection return connection
@pytest.fixture @pytest.fixture
def mock_dispatcher(): def mock_dispatcher():
dispatcher = MagicMock() dispatcher = MagicMock()
@ -20,29 +22,36 @@ def mock_dispatcher():
dispatcher.dispatch = AsyncMock() dispatcher.dispatch = AsyncMock()
return dispatcher return dispatcher
@pytest.fixture @pytest.fixture
def command_handler(mock_connection, mock_dispatcher): def command_handler(mock_connection, mock_dispatcher):
handler = CommandHandler() handler = CommandHandler()
async def sender(data): async def sender(data):
await mock_connection.send(data) await mock_connection.send(data)
handler._sender_func = sender handler._sender_func = sender
handler.dispatcher = mock_dispatcher handler.dispatcher = mock_dispatcher
return handler return handler
# Test helper # Test helper
def setup_event_response(mock_dispatcher, event_type, payload, attribute_filters=None): def setup_event_response(mock_dispatcher, event_type, payload, attribute_filters=None):
async def wait_response(requested_type, filters=None, timeout=None): async def wait_response(requested_type, filters=None, timeout=None):
if requested_type == event_type: if requested_type == event_type:
if filters and attribute_filters: if filters and attribute_filters:
if not all(attribute_filters.get(key) == value for key, value in filters.items()): if not all(
attribute_filters.get(key) == value
for key, value in filters.items()
):
return None return None
return Event(event_type, payload) return Event(event_type, payload)
return None return None
mock_dispatcher.wait_for_event.side_effect = wait_response mock_dispatcher.wait_for_event.side_effect = wait_response
# Basic tests # Basic tests
async def test_send_basic(command_handler, mock_connection): async def test_send_basic(command_handler, mock_connection):
result = await command_handler.send(b"test_data") result = await command_handler.send(b"test_data")
@ -50,6 +59,7 @@ async def test_send_basic(command_handler, mock_connection):
assert result.type == EventType.OK assert result.type == EventType.OK
assert result.payload == {} assert result.payload == {}
async def test_send_with_event(command_handler, mock_connection, mock_dispatcher): async def test_send_with_event(command_handler, mock_connection, mock_dispatcher):
expected_payload = {"value": 42} expected_payload = {"value": 42}
setup_event_response(mock_dispatcher, EventType.OK, expected_payload) setup_event_response(mock_dispatcher, EventType.OK, expected_payload)
@ -60,6 +70,7 @@ async def test_send_with_event(command_handler, mock_connection, mock_dispatcher
assert result.type == EventType.OK assert result.type == EventType.OK
assert result.payload == expected_payload assert result.payload == expected_payload
async def test_send_timeout(command_handler, mock_connection, mock_dispatcher): async def test_send_timeout(command_handler, mock_connection, mock_dispatcher):
mock_dispatcher.wait_for_event.side_effect = asyncio.TimeoutError mock_dispatcher.wait_for_event.side_effect = asyncio.TimeoutError
@ -67,6 +78,7 @@ async def test_send_timeout(command_handler, mock_connection, mock_dispatcher):
assert result.type == EventType.ERROR assert result.type == EventType.ERROR
assert result.payload == {"reason": "timeout"} assert result.payload == {"reason": "timeout"}
# Destination validation tests # Destination validation tests
async def test_validate_destination_bytes(command_handler, mock_connection): async def test_validate_destination_bytes(command_handler, mock_connection):
dst = b"123456789012" # 12 bytes dst = b"123456789012" # 12 bytes
@ -75,6 +87,7 @@ async def test_validate_destination_bytes(command_handler, mock_connection):
assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00")
assert b"123456" in mock_connection.send.call_args[0][0] assert b"123456" in mock_connection.send.call_args[0][0]
async def test_validate_destination_hex_string(command_handler, mock_connection): async def test_validate_destination_hex_string(command_handler, mock_connection):
dst = "0123456789abcdef" dst = "0123456789abcdef"
await command_handler.send_msg(dst, "test message") await command_handler.send_msg(dst, "test message")
@ -82,6 +95,7 @@ async def test_validate_destination_hex_string(command_handler, mock_connection)
assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_validate_destination_contact_object(command_handler, mock_connection): async def test_validate_destination_contact_object(command_handler, mock_connection):
dst = {"public_key": "0123456789abcdef", "adv_name": "Test Contact"} dst = {"public_key": "0123456789abcdef", "adv_name": "Test Contact"}
await command_handler.send_msg(dst, "test message") await command_handler.send_msg(dst, "test message")
@ -89,6 +103,7 @@ async def test_validate_destination_contact_object(command_handler, mock_connect
assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00") assert mock_connection.send.call_args[0][0].startswith(b"\x02\x00\x00")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
# Command tests # Command tests
async def test_send_login(command_handler, mock_connection): async def test_send_login(command_handler, mock_connection):
await command_handler.send_login("0123456789abcdef", "password") await command_handler.send_login("0123456789abcdef", "password")
@ -97,6 +112,7 @@ async def test_send_login(command_handler, mock_connection):
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
assert b"password" in mock_connection.send.call_args[0][0] assert b"password" in mock_connection.send.call_args[0][0]
async def test_send_msg(command_handler, mock_connection): async def test_send_msg(command_handler, mock_connection):
await command_handler.send_msg("0123456789abcdef", "hello") await command_handler.send_msg("0123456789abcdef", "hello")
@ -104,6 +120,7 @@ async def test_send_msg(command_handler, mock_connection):
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
assert b"hello" in mock_connection.send.call_args[0][0] assert b"hello" in mock_connection.send.call_args[0][0]
async def test_send_cmd(command_handler, mock_connection): async def test_send_cmd(command_handler, mock_connection):
await command_handler.send_cmd("0123456789abcdef", "test_cmd") await command_handler.send_cmd("0123456789abcdef", "test_cmd")
@ -111,6 +128,7 @@ async def test_send_cmd(command_handler, mock_connection):
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
assert b"test_cmd" in mock_connection.send.call_args[0][0] assert b"test_cmd" in mock_connection.send.call_args[0][0]
# Device settings tests # Device settings tests
async def test_set_name(command_handler, mock_connection): async def test_set_name(command_handler, mock_connection):
await command_handler.set_name("Test Device") await command_handler.set_name("Test Device")
@ -118,21 +136,25 @@ async def test_set_name(command_handler, mock_connection):
assert mock_connection.send.call_args[0][0].startswith(b"\x08") assert mock_connection.send.call_args[0][0].startswith(b"\x08")
assert b"Test Device" in mock_connection.send.call_args[0][0] assert b"Test Device" in mock_connection.send.call_args[0][0]
async def test_set_coords(command_handler, mock_connection): async def test_set_coords(command_handler, mock_connection):
await command_handler.set_coords(37.7749, -122.4194) await command_handler.set_coords(37.7749, -122.4194)
assert mock_connection.send.call_args[0][0].startswith(b"\x0e") assert mock_connection.send.call_args[0][0].startswith(b"\x0e")
# Could add more detailed assertions for the byte encoding # Could add more detailed assertions for the byte encoding
async def test_send_appstart(command_handler, mock_connection): async def test_send_appstart(command_handler, mock_connection):
await command_handler.send_appstart() await command_handler.send_appstart()
assert mock_connection.send.call_args[0][0].startswith(b"\x01\x03") assert mock_connection.send.call_args[0][0].startswith(b"\x01\x03")
assert b"mccli" in mock_connection.send.call_args[0][0] assert b"mccli" in mock_connection.send.call_args[0][0]
async def test_send_device_query(command_handler, mock_connection): async def test_send_device_query(command_handler, mock_connection):
await command_handler.send_device_query() await command_handler.send_device_query()
assert mock_connection.send.call_args[0][0].startswith(b"\x16\x03") assert mock_connection.send.call_args[0][0].startswith(b"\x16\x03")
async def test_send_advert(command_handler, mock_connection): async def test_send_advert(command_handler, mock_connection):
# Test without flood # Test without flood
await command_handler.send_advert(flood=False) await command_handler.send_advert(flood=False)
@ -143,43 +165,52 @@ async def test_send_advert(command_handler, mock_connection):
await command_handler.send_advert(flood=True) await command_handler.send_advert(flood=True)
assert mock_connection.send.call_args[0][0] == b"\x07\x01" assert mock_connection.send.call_args[0][0] == b"\x07\x01"
async def test_reboot(command_handler, mock_connection): async def test_reboot(command_handler, mock_connection):
await command_handler.reboot() await command_handler.reboot()
assert mock_connection.send.call_args[0][0].startswith(b"\x13reboot") assert mock_connection.send.call_args[0][0].startswith(b"\x13reboot")
async def test_get_bat(command_handler, mock_connection): async def test_get_bat(command_handler, mock_connection):
await command_handler.get_bat() await command_handler.get_bat()
assert mock_connection.send.call_args[0][0].startswith(b"\x14") assert mock_connection.send.call_args[0][0].startswith(b"\x14")
async def test_get_time(command_handler, mock_connection): async def test_get_time(command_handler, mock_connection):
await command_handler.get_time() await command_handler.get_time()
assert mock_connection.send.call_args[0][0].startswith(b"\x05") assert mock_connection.send.call_args[0][0].startswith(b"\x05")
async def test_set_time(command_handler, mock_connection): async def test_set_time(command_handler, mock_connection):
timestamp = 1620000000 # Example timestamp timestamp = 1620000000 # Example timestamp
await command_handler.set_time(timestamp) await command_handler.set_time(timestamp)
assert mock_connection.send.call_args[0][0].startswith(b"\x06") assert mock_connection.send.call_args[0][0].startswith(b"\x06")
async def test_set_tx_power(command_handler, mock_connection): async def test_set_tx_power(command_handler, mock_connection):
await command_handler.set_tx_power(20) await command_handler.set_tx_power(20)
assert mock_connection.send.call_args[0][0].startswith(b"\x0c") assert mock_connection.send.call_args[0][0].startswith(b"\x0c")
async def test_get_contacts(command_handler, mock_connection): async def test_get_contacts(command_handler, mock_connection):
await command_handler.get_contacts() await command_handler.get_contacts()
assert mock_connection.send.call_args[0][0].startswith(b"\x04") assert mock_connection.send.call_args[0][0].startswith(b"\x04")
async def test_reset_path(command_handler, mock_connection): async def test_reset_path(command_handler, mock_connection):
dst = "0123456789abcdef" dst = "0123456789abcdef"
await command_handler.reset_path(dst) await command_handler.reset_path(dst)
assert mock_connection.send.call_args[0][0].startswith(b"\x0D") assert mock_connection.send.call_args[0][0].startswith(b"\x0d")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_share_contact(command_handler, mock_connection): async def test_share_contact(command_handler, mock_connection):
dst = "0123456789abcdef" dst = "0123456789abcdef"
await command_handler.share_contact(dst) await command_handler.share_contact(dst)
assert mock_connection.send.call_args[0][0].startswith(b"\x10") assert mock_connection.send.call_args[0][0].startswith(b"\x10")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_export_contact(command_handler, mock_connection): async def test_export_contact(command_handler, mock_connection):
# Test exporting all contacts # Test exporting all contacts
await command_handler.export_contact() await command_handler.export_contact()
@ -192,20 +223,23 @@ async def test_export_contact(command_handler, mock_connection):
assert mock_connection.send.call_args[0][0].startswith(b"\x11") assert mock_connection.send.call_args[0][0].startswith(b"\x11")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_remove_contact(command_handler, mock_connection): async def test_remove_contact(command_handler, mock_connection):
dst = "0123456789abcdef" dst = "0123456789abcdef"
await command_handler.remove_contact(dst) await command_handler.remove_contact(dst)
assert mock_connection.send.call_args[0][0].startswith(b"\x0f") assert mock_connection.send.call_args[0][0].startswith(b"\x0f")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_get_msg(command_handler, mock_connection): async def test_get_msg(command_handler, mock_connection):
await command_handler.get_msg() await command_handler.get_msg()
assert mock_connection.send.call_args[0][0].startswith(b"\x0A") assert mock_connection.send.call_args[0][0].startswith(b"\x0a")
# Test with custom timeout # Test with custom timeout
mock_connection.reset_mock() mock_connection.reset_mock()
await command_handler.get_msg(timeout=5.0) await command_handler.get_msg(timeout=5.0)
assert mock_connection.send.call_args[0][0].startswith(b"\x0A") assert mock_connection.send.call_args[0][0].startswith(b"\x0a")
async def test_send_logout(command_handler, mock_connection): async def test_send_logout(command_handler, mock_connection):
dst = "0123456789abcdef" dst = "0123456789abcdef"
@ -213,12 +247,14 @@ async def test_send_logout(command_handler, mock_connection):
assert mock_connection.send.call_args[0][0].startswith(b"\x1d") assert mock_connection.send.call_args[0][0].startswith(b"\x1d")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_send_statusreq(command_handler, mock_connection): async def test_send_statusreq(command_handler, mock_connection):
dst = "0123456789abcdef" dst = "0123456789abcdef"
await command_handler.send_statusreq(dst) await command_handler.send_statusreq(dst)
assert mock_connection.send.call_args[0][0].startswith(b"\x1b") assert mock_connection.send.call_args[0][0].startswith(b"\x1b")
assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0] assert b"\x01\x23\x45\x67\x89\xab" in mock_connection.send.call_args[0][0]
async def test_send_trace(command_handler, mock_connection): async def test_send_trace(command_handler, mock_connection):
# Test with minimal parameters # Test with minimal parameters
await command_handler.send_trace() await command_handler.send_trace()
@ -228,15 +264,15 @@ async def test_send_trace(command_handler, mock_connection):
# Test with all parameters # Test with all parameters
mock_connection.reset_mock() mock_connection.reset_mock()
await command_handler.send_trace( await command_handler.send_trace(
auth_code=12345, auth_code=12345, tag=67890, flags=1, path="01,23,45"
tag=67890,
flags=1,
path="01,23,45"
) )
second_call = mock_connection.send.call_args[0][0] second_call = mock_connection.send.call_args[0][0]
assert second_call.startswith(b"\x24") assert second_call.startswith(b"\x24")
async def test_send_with_multiple_expected_events_returns_first_completed(command_handler, mock_connection, mock_dispatcher):
async def test_send_with_multiple_expected_events_returns_first_completed(
command_handler, mock_connection, mock_dispatcher
):
# Setup the dispatcher to return an ERROR event # Setup the dispatcher to return an ERROR event
error_payload = {"reason": "command_failed"} error_payload = {"reason": "command_failed"}
@ -248,7 +284,9 @@ async def test_send_with_multiple_expected_events_returns_first_completed(comman
mock_dispatcher.wait_for_event.side_effect = simulate_error_event mock_dispatcher.wait_for_event.side_effect = simulate_error_event
# Call send with both OK and ERROR in the expected_events list, with OK first # Call send with both OK and ERROR in the expected_events list, with OK first
result = await command_handler.send(b"test_command", [EventType.OK, EventType.ERROR]) result = await command_handler.send(
b"test_command", [EventType.OK, EventType.ERROR]
)
# Verify the command was sent # Verify the command was sent
mock_connection.send.assert_called_once_with(b"test_command") mock_connection.send.assert_called_once_with(b"test_command")
@ -257,21 +295,26 @@ async def test_send_with_multiple_expected_events_returns_first_completed(comman
assert result.type == EventType.ERROR assert result.type == EventType.ERROR
assert result.payload == error_payload assert result.payload == error_payload
# Channel command tests # Channel command tests
async def test_get_channel(command_handler, mock_connection): async def test_get_channel(command_handler, mock_connection):
await command_handler.get_channel(3) await command_handler.get_channel(3)
assert mock_connection.send.call_args[0][0] == b"\x1f\x03" assert mock_connection.send.call_args[0][0] == b"\x1f\x03"
async def test_set_channel(command_handler, mock_connection): async def test_set_channel(command_handler, mock_connection):
channel_secret = bytes(range(16)) # 16 bytes: 0x00, 0x01, ..., 0x0f channel_secret = bytes(range(16)) # 16 bytes: 0x00, 0x01, ..., 0x0f
await command_handler.set_channel(5, "MyChannel", channel_secret) await command_handler.set_channel(5, "MyChannel", channel_secret)
expected_data = b"\x20\x05" # CMD_SET_CHANNEL + channel_idx=5 expected_data = b"\x20\x05" # CMD_SET_CHANNEL + channel_idx=5
expected_data += b"MyChannel" + b"\x00" * (32 - len("MyChannel")) # 32-byte padded name expected_data += b"MyChannel" + b"\x00" * (
32 - len("MyChannel")
) # 32-byte padded name
expected_data += channel_secret # 16-byte secret expected_data += channel_secret # 16-byte secret
assert mock_connection.send.call_args[0][0] == expected_data assert mock_connection.send.call_args[0][0] == expected_data
async def test_set_channel_invalid_secret_length(command_handler): async def test_set_channel_invalid_secret_length(command_handler):
with pytest.raises(ValueError, match="Channel secret must be exactly 16 bytes"): with pytest.raises(ValueError, match="Channel secret must be exactly 16 bytes"):
await command_handler.set_channel(1, "Test", b"tooshort") await command_handler.set_channel(1, "Test", b"tooshort")

View file

@ -1,22 +1,24 @@
import pytest import pytest
import asyncio import asyncio
from unittest.mock import MagicMock, AsyncMock from unittest.mock import MagicMock
from meshcore.events import EventDispatcher, EventType, Event from meshcore.events import EventDispatcher, EventType, Event
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@pytest.fixture @pytest.fixture
def dispatcher(): def dispatcher():
return EventDispatcher() return EventDispatcher()
async def test_subscribe_with_attribute_filter(dispatcher): async def test_subscribe_with_attribute_filter(dispatcher):
callback = MagicMock() callback = MagicMock()
# Subscribe with attribute filters # Subscribe with attribute filters
subscription = dispatcher.subscribe( dispatcher.subscribe(
EventType.MSG_SENT, EventType.MSG_SENT,
callback, callback,
attribute_filters={"type": 1, "expected_ack": "1234"} attribute_filters={"type": 1, "expected_ack": "1234"},
) )
# Start the dispatcher # Start the dispatcher
@ -24,22 +26,26 @@ async def test_subscribe_with_attribute_filter(dispatcher):
try: try:
# Dispatch event that should NOT match (wrong type) # Dispatch event that should NOT match (wrong type)
await dispatcher.dispatch(Event( await dispatcher.dispatch(
EventType.MSG_SENT, Event(
{"some": "data"}, EventType.MSG_SENT,
{"type": 2, "expected_ack": "1234"} {"some": "data"},
)) {"type": 2, "expected_ack": "1234"},
)
)
await asyncio.sleep(0.1) # Allow processing await asyncio.sleep(0.1) # Allow processing
# Callback should NOT have been called # Callback should NOT have been called
assert callback.call_count == 0 assert callback.call_count == 0
# Dispatch event that should match all filters # Dispatch event that should match all filters
await dispatcher.dispatch(Event( await dispatcher.dispatch(
EventType.MSG_SENT, Event(
{"some": "data"}, EventType.MSG_SENT,
{"type": 1, "expected_ack": "1234"} {"some": "data"},
)) {"type": 1, "expected_ack": "1234"},
)
)
await asyncio.sleep(0.1) # Allow processing await asyncio.sleep(0.1) # Allow processing
# Callback should have been called once # Callback should have been called once
@ -48,33 +54,28 @@ async def test_subscribe_with_attribute_filter(dispatcher):
finally: finally:
await dispatcher.stop() await dispatcher.stop()
async def test_wait_for_event_with_attribute_filter(dispatcher): async def test_wait_for_event_with_attribute_filter(dispatcher):
await dispatcher.start() await dispatcher.start()
try: try:
future_event = asyncio.create_task( future_event = asyncio.create_task(
dispatcher.wait_for_event( dispatcher.wait_for_event(
EventType.ACK, EventType.ACK, attribute_filters={"code": "1234"}, timeout=3.0
attribute_filters={"code": "1234"},
timeout=3.0
) )
) )
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
await dispatcher.dispatch(Event( await dispatcher.dispatch(
EventType.ACK, Event(EventType.ACK, {"some": "data"}, {"code": "5678"})
{"some": "data"}, )
{"code": "5678"}
))
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
await dispatcher.dispatch(Event( await dispatcher.dispatch(
EventType.ACK, Event(EventType.ACK, {"ack": "data"}, {"code": "1234"})
{"ack": "data"}, )
{"code": "1234"}
))
result = await asyncio.wait_for(future_event, 3.0) result = await asyncio.wait_for(future_event, 3.0)
@ -86,15 +87,14 @@ async def test_wait_for_event_with_attribute_filter(dispatcher):
finally: finally:
await dispatcher.stop() await dispatcher.stop()
async def test_wait_for_event_timeout_with_filter(dispatcher): async def test_wait_for_event_timeout_with_filter(dispatcher):
await dispatcher.start() await dispatcher.start()
try: try:
# Wait for an event that won't arrive # Wait for an event that won't arrive
result = await dispatcher.wait_for_event( result = await dispatcher.wait_for_event(
EventType.ACK, EventType.ACK, attribute_filters={"code": "1234"}, timeout=0.1
attribute_filters={"code": "1234"},
timeout=0.1
) )
# Should get None due to timeout # Should get None due to timeout
@ -103,6 +103,7 @@ async def test_wait_for_event_timeout_with_filter(dispatcher):
finally: finally:
await dispatcher.stop() await dispatcher.stop()
async def test_event_init_with_kwargs(): async def test_event_init_with_kwargs():
# Test creating an event with keyword attributes # Test creating an event with keyword attributes
event = Event(EventType.ACK, {"data": "value"}, code="1234", status="ok") event = Event(EventType.ACK, {"data": "value"}, code="1234", status="ok")
@ -111,12 +112,13 @@ async def test_event_init_with_kwargs():
assert event.payload == {"data": "value"} assert event.payload == {"data": "value"}
assert event.attributes == {"code": "1234", "status": "ok"} assert event.attributes == {"code": "1234", "status": "ok"}
async def test_channel_info_event(): async def test_channel_info_event():
# Test CHANNEL_INFO event type # Test CHANNEL_INFO event type
channel_payload = { channel_payload = {
"channel_idx": 3, "channel_idx": 3,
"channel_name": "TestChannel", "channel_name": "TestChannel",
"channel_secret": b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10" "channel_secret": b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10",
} }
event = Event(EventType.CHANNEL_INFO, channel_payload) event = Event(EventType.CHANNEL_INFO, channel_payload)