mirror of
https://github.com/meshcore-dev/meshcore_py.git
synced 2026-06-11 11:56:18 +00:00
Revert "Refactor command system to be queue based"
This reverts commit 28957a4b60.
This commit is contained in:
176
README.md
176
README.md
@@ -399,6 +399,182 @@ async def channel_handler(event):
|
|||||||
meshcore.subscribe(EventType.CHANNEL_MSG_RECV, channel_handler)
|
meshcore.subscribe(EventType.CHANNEL_MSG_RECV, channel_handler)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
All events in MeshCore are represented by the `EventType` enum. These events are dispatched by the library and can be subscribed to:
|
||||||
|
|
||||||
|
| Event Type | String Value | Description | Typical Payload |
|
||||||
|
|------------|-------------|-------------|-----------------|
|
||||||
|
| **Device & Status Events** |||
|
||||||
|
| `SELF_INFO` | `"self_info"` | Device's own information after appstart | Device configuration, public key, coordinates |
|
||||||
|
| `DEVICE_INFO` | `"device_info"` | Device capabilities and firmware info | Firmware version, model, max contacts/channels |
|
||||||
|
| `BATTERY` | `"battery_info"` | Battery level and storage info | Battery level, used/total storage |
|
||||||
|
| `CURRENT_TIME` | `"time_update"` | Device time response | Current timestamp |
|
||||||
|
| `STATUS_RESPONSE` | `"status_response"` | Device status statistics | Battery, TX queue, noise floor, packet counts |
|
||||||
|
| `CUSTOM_VARS` | `"custom_vars"` | Custom variable responses | Key-value pairs of custom variables |
|
||||||
|
| **Contact Events** |||
|
||||||
|
| `CONTACTS` | `"contacts"` | Contact list response | Dictionary of contacts by public key |
|
||||||
|
| `NEW_CONTACT` | `"new_contact"` | New contact discovered | Contact information |
|
||||||
|
| `CONTACT_URI` | `"contact_uri"` | Contact export URI | Shareable contact URI |
|
||||||
|
| **Messaging Events** |||
|
||||||
|
| `CONTACT_MSG_RECV` | `"contact_message"` | Direct message received | Message text, sender prefix, timestamp |
|
||||||
|
| `CHANNEL_MSG_RECV` | `"channel_message"` | Channel message received | Message text, channel index, timestamp |
|
||||||
|
| `MSG_SENT` | `"message_sent"` | Message send confirmation | Expected ACK code, suggested timeout |
|
||||||
|
| `NO_MORE_MSGS` | `"no_more_messages"` | No pending messages | Empty payload |
|
||||||
|
| `MESSAGES_WAITING` | `"messages_waiting"` | Messages available notification | Empty payload |
|
||||||
|
| **Network Events** |||
|
||||||
|
| `ADVERTISEMENT` | `"advertisement"` | Node advertisement detected | Public key of advertising node |
|
||||||
|
| `PATH_UPDATE` | `"path_update"` | Routing path update | Public key and path information |
|
||||||
|
| `ACK` | `"acknowledgement"` | Message acknowledgment | ACK code |
|
||||||
|
| `PATH_RESPONSE` | `"path_response"` | Path discovery response | Inbound/outbound path data |
|
||||||
|
| `TRACE_DATA` | `"trace_data"` | Route trace information | Path with SNR data for each hop |
|
||||||
|
| **Telemetry Events** |||
|
||||||
|
| `TELEMETRY_RESPONSE` | `"telemetry_response"` | Telemetry data response | LPP-formatted sensor data |
|
||||||
|
| `MMA_RESPONSE` | `"mma_response"` | Memory Management Area data | Min/max/avg telemetry over time range |
|
||||||
|
| `ACL_RESPONSE` | `"acl_response"` | Access Control List data | List of keys and permissions |
|
||||||
|
| **Channel Events** |||
|
||||||
|
| `CHANNEL_INFO` | `"channel_info"` | Channel configuration | Channel name, secret, index |
|
||||||
|
| **Raw Data Events** |||
|
||||||
|
| `RAW_DATA` | `"raw_data"` | Raw radio data | SNR, RSSI, payload hex |
|
||||||
|
| `RX_LOG_DATA` | `"rx_log_data"` | RF log data | SNR, RSSI, raw payload |
|
||||||
|
| `LOG_DATA` | `"log_data"` | Generic log data | Various log information |
|
||||||
|
| **Binary Protocol Events** |||
|
||||||
|
| `BINARY_RESPONSE` | `"binary_response"` | Generic binary response | Tag and hex data |
|
||||||
|
| **Authentication Events** |||
|
||||||
|
| `LOGIN_SUCCESS` | `"login_success"` | Successful login | Permissions, admin status, pubkey prefix |
|
||||||
|
| `LOGIN_FAILED` | `"login_failed"` | Failed login attempt | Pubkey prefix |
|
||||||
|
| **Command Response Events** |||
|
||||||
|
| `OK` | `"command_ok"` | Command successful | Success confirmation, optional value |
|
||||||
|
| `ERROR` | `"command_error"` | Command failed | Error reason or code |
|
||||||
|
| **Connection Events** |||
|
||||||
|
| `CONNECTED` | `"connected"` | Connection established | Connection details, reconnection status |
|
||||||
|
| `DISCONNECTED` | `"disconnected"` | Connection lost | Disconnection reason |
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
All commands are async methods that return `Event` objects. Commands are organized into functional groups:
|
||||||
|
|
||||||
|
#### Device Commands (`meshcore.commands.*`)
|
||||||
|
|
||||||
|
| Command | Parameters | Returns | Description |
|
||||||
|
|---------|------------|---------|-------------|
|
||||||
|
| **Device Information** ||||
|
||||||
|
| `send_appstart()` | None | `SELF_INFO` | Get device self-information and configuration |
|
||||||
|
| `send_device_query()` | None | `DEVICE_INFO` | Query device capabilities and firmware info |
|
||||||
|
| `get_bat()` | None | `BATTERY` | Get battery level and storage information |
|
||||||
|
| `get_time()` | None | `CURRENT_TIME` | Get current device time |
|
||||||
|
| `get_self_telemetry()` | None | `TELEMETRY_RESPONSE` | Get device's own telemetry data |
|
||||||
|
| `get_custom_vars()` | None | `CUSTOM_VARS` | Retrieve all custom variables |
|
||||||
|
| **Device Configuration** ||||
|
||||||
|
| `set_name(name)` | `name: str` | `OK` | Set device name/identifier |
|
||||||
|
| `set_coords(lat, lon)` | `lat: float, lon: float` | `OK` | Set device GPS coordinates |
|
||||||
|
| `set_time(val)` | `val: int` | `OK` | Set device time (Unix timestamp) |
|
||||||
|
| `set_tx_power(val)` | `val: int` | `OK` | Set radio transmission power level |
|
||||||
|
| `set_devicepin(pin)` | `pin: int` | `OK` | Set device PIN for security |
|
||||||
|
| `set_custom_var(key, value)` | `key: str, value: str` | `OK` | Set custom variable |
|
||||||
|
| **Radio Configuration** ||||
|
||||||
|
| `set_radio(freq, bw, sf, cr)` | `freq: float, bw: float, sf: int, cr: int` | `OK` | Configure radio (freq MHz, bandwidth kHz, spreading factor, coding rate 5-8) |
|
||||||
|
| `set_tuning(rx_dly, af)` | `rx_dly: int, af: int` | `OK` | Set radio tuning parameters |
|
||||||
|
| **Telemetry Configuration** ||||
|
||||||
|
| `set_telemetry_mode_base(mode)` | `mode: int` | `OK` | Set base telemetry mode |
|
||||||
|
| `set_telemetry_mode_loc(mode)` | `mode: int` | `OK` | Set location telemetry mode |
|
||||||
|
| `set_telemetry_mode_env(mode)` | `mode: int` | `OK` | Set environmental telemetry mode |
|
||||||
|
| `set_manual_add_contacts(enabled)` | `enabled: bool` | `OK` | Enable/disable manual contact addition |
|
||||||
|
| `set_advert_loc_policy(policy)` | `policy: int` | `OK` | Set location advertisement policy |
|
||||||
|
| **Channel Management** ||||
|
||||||
|
| `get_channel(channel_idx)` | `channel_idx: int` | `CHANNEL_INFO` | Get channel configuration |
|
||||||
|
| `set_channel(channel_idx, name, secret)` | `channel_idx: int, name: str, secret: bytes` | `OK` | Configure channel (secret must be 16 bytes) |
|
||||||
|
| **Device Actions** ||||
|
||||||
|
| `send_advert(flood=False)` | `flood: bool` | `OK` | Send advertisement (optionally flood network) |
|
||||||
|
| `reboot()` | None | None | Reboot device (no response expected) |
|
||||||
|
|
||||||
|
#### Contact Commands (`meshcore.commands.*`)
|
||||||
|
|
||||||
|
| Command | Parameters | Returns | Description |
|
||||||
|
|---------|------------|---------|-------------|
|
||||||
|
| **Contact Management** ||||
|
||||||
|
| `get_contacts(lastmod=0)` | `lastmod: int` | `CONTACTS` | Get contact list (filter by last modification time) |
|
||||||
|
| `add_contact(contact)` | `contact: dict` | `OK` | Add new contact to device |
|
||||||
|
| `update_contact(contact, path, flags)` | `contact: dict, path: bytes, flags: int` | `OK` | Update existing contact |
|
||||||
|
| `remove_contact(key)` | `key: str/bytes` | `OK` | Remove contact by public key |
|
||||||
|
| **Contact Operations** ||||
|
||||||
|
| `reset_path(key)` | `key: str/bytes` | `OK` | Reset routing path for contact |
|
||||||
|
| `share_contact(key)` | `key: str/bytes` | `OK` | Share contact with network |
|
||||||
|
| `export_contact(key=None)` | `key: str/bytes/None` | `CONTACT_URI` | Export contact as URI (None exports node) |
|
||||||
|
| `import_contact(card_data)` | `card_data: bytes` | `OK` | Import contact from card data |
|
||||||
|
| **Contact Modification** ||||
|
||||||
|
| `change_contact_path(contact, path)` | `contact: dict, path: bytes` | `OK` | Change routing path for contact |
|
||||||
|
| `change_contact_flags(contact, flags)` | `contact: dict, flags: int` | `OK` | Change contact flags/settings |
|
||||||
|
|
||||||
|
#### Messaging Commands (`meshcore.commands.*`)
|
||||||
|
|
||||||
|
| Command | Parameters | Returns | Description |
|
||||||
|
|---------|------------|---------|-------------|
|
||||||
|
| **Message Handling** ||||
|
||||||
|
| `get_msg(timeout=None)` | `timeout: float` | `CONTACT_MSG_RECV/CHANNEL_MSG_RECV/NO_MORE_MSGS` | Get next pending message |
|
||||||
|
| `send_msg(dst, msg, timestamp=None)` | `dst: contact/str/bytes, msg: str, timestamp: int` | `MSG_SENT` | Send direct message |
|
||||||
|
| `send_cmd(dst, cmd, timestamp=None)` | `dst: contact/str/bytes, cmd: str, timestamp: int` | `MSG_SENT` | Send command message |
|
||||||
|
| `send_chan_msg(chan, msg, timestamp=None)` | `chan: int, msg: str, timestamp: int` | `MSG_SENT` | Send channel message |
|
||||||
|
| **Authentication** ||||
|
||||||
|
| `send_login(dst, pwd)` | `dst: contact/str/bytes, pwd: str` | `MSG_SENT` | Send login request |
|
||||||
|
| `send_logout(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Send logout request |
|
||||||
|
| **Information Requests** ||||
|
||||||
|
| `send_statusreq(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Request status from contact |
|
||||||
|
| `send_telemetry_req(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Request telemetry from contact |
|
||||||
|
| **Advanced Messaging** ||||
|
||||||
|
| `send_binary_req(dst, bin_data)` | `dst: contact/str/bytes, bin_data: bytes` | `MSG_SENT` | Send binary data request |
|
||||||
|
| `send_path_discovery(dst)` | `dst: contact/str/bytes` | `MSG_SENT` | Initiate path discovery |
|
||||||
|
| `send_trace(auth_code, tag, flags, path=None)` | `auth_code: int, tag: int, flags: int, path: list` | `MSG_SENT` | Send route trace packet |
|
||||||
|
|
||||||
|
#### Binary Protocol Commands (`meshcore.commands.*`)
|
||||||
|
|
||||||
|
| Command | Parameters | Returns | Description |
|
||||||
|
|---------|------------|---------|-------------|
|
||||||
|
| `req_status(contact, timeout=0)` | `contact: dict, timeout: float` | `STATUS_RESPONSE` | Get detailed status via binary protocol |
|
||||||
|
| `req_telemetry(contact, timeout=0)` | `contact: dict, timeout: float` | `TELEMETRY_RESPONSE` | Get telemetry via binary protocol |
|
||||||
|
| `req_mma(contact, start, end, timeout=0)` | `contact: dict, start: int, end: int, timeout: float` | `MMA_RESPONSE` | Get historical telemetry data |
|
||||||
|
| `req_acl(contact, timeout=0)` | `contact: dict, timeout: float` | `ACL_RESPONSE` | Get access control list |
|
||||||
|
|
||||||
|
### Helper Methods
|
||||||
|
|
||||||
|
| Method | Returns | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `get_contact_by_name(name)` | `dict/None` | Find contact by advertisement name |
|
||||||
|
| `get_contact_by_key_prefix(prefix)` | `dict/None` | Find contact by partial public key |
|
||||||
|
| `is_connected` | `bool` | Check if device is currently connected |
|
||||||
|
| `subscribe(event_type, callback, filters=None)` | `Subscription` | Subscribe to events with optional filtering |
|
||||||
|
| `unsubscribe(subscription)` | None | Remove event subscription |
|
||||||
|
| `wait_for_event(event_type, filters=None, timeout=None)` | `Event/None` | Wait for specific event |
|
||||||
|
|
||||||
|
### Event Filtering
|
||||||
|
|
||||||
|
Events can be filtered by their attributes when subscribing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Filter by public key prefix
|
||||||
|
meshcore.subscribe(
|
||||||
|
EventType.CONTACT_MSG_RECV,
|
||||||
|
handler,
|
||||||
|
attribute_filters={"pubkey_prefix": "a1b2c3d4e5f6"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by channel index
|
||||||
|
meshcore.subscribe(
|
||||||
|
EventType.CHANNEL_MSG_RECV,
|
||||||
|
handler,
|
||||||
|
attribute_filters={"channel_idx": 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter acknowledgments by code
|
||||||
|
meshcore.subscribe(
|
||||||
|
EventType.ACK,
|
||||||
|
handler,
|
||||||
|
attribute_filters={"code": "12345678"}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
## Examples in the Repo
|
## Examples in the Repo
|
||||||
|
|
||||||
Check the `examples/` directory for more:
|
Check the `examples/` directory for more:
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import logging
|
|
||||||
from enum import Enum
|
|
||||||
import json
|
|
||||||
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):
|
|
||||||
STATUS = 0x01
|
|
||||||
KEEP_ALIVE = 0x02
|
|
||||||
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)
|
|
||||||
if lpp_type is None:
|
|
||||||
logger.error(f"Unknown LPP type: {type}")
|
|
||||||
return None
|
|
||||||
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
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, Union
|
from typing import Any, Callable, Coroutine, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from meshcore.packets import BinaryReqType
|
||||||
|
|
||||||
from ..events import Event, EventDispatcher, EventType
|
from ..events import Event, EventDispatcher, EventType
|
||||||
from ..reader import MessageReader
|
from ..reader import MessageReader
|
||||||
|
|
||||||
@@ -52,9 +55,8 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
|
|||||||
|
|
||||||
class CommandHandlerBase:
|
class CommandHandlerBase:
|
||||||
DEFAULT_TIMEOUT = 5.0
|
DEFAULT_TIMEOUT = 5.0
|
||||||
MAX_QUEUE_SIZE = 100
|
|
||||||
|
|
||||||
def __init__(self, default_timeout: Optional[float] = None, max_queue_size: Optional[int] = None):
|
def __init__(self, default_timeout: Optional[float] = None):
|
||||||
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
|
self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None
|
||||||
self._reader: Optional[MessageReader] = None
|
self._reader: Optional[MessageReader] = None
|
||||||
self.dispatcher: Optional[EventDispatcher] = None
|
self.dispatcher: Optional[EventDispatcher] = None
|
||||||
@@ -62,12 +64,6 @@ class CommandHandlerBase:
|
|||||||
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
|
default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
max_size = max_queue_size if max_queue_size is not None else self.MAX_QUEUE_SIZE
|
|
||||||
self._command_queue = asyncio.Queue(maxsize=max_size)
|
|
||||||
self._start_lock = asyncio.Lock() # Only for start/stop operations
|
|
||||||
self._queue_processor_task: Optional[asyncio.Task] = None
|
|
||||||
self._is_running = False
|
|
||||||
|
|
||||||
def set_connection(self, connection: Any) -> None:
|
def set_connection(self, connection: Any) -> None:
|
||||||
async def sender(data: bytes) -> None:
|
async def sender(data: bytes) -> None:
|
||||||
await connection.send(data)
|
await connection.send(data)
|
||||||
@@ -87,48 +83,7 @@ class CommandHandlerBase:
|
|||||||
timeout: Optional[float] = None,
|
timeout: Optional[float] = None,
|
||||||
) -> Event:
|
) -> Event:
|
||||||
"""
|
"""
|
||||||
Queue a command for execution and wait for the response.
|
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
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If the command queue is full
|
|
||||||
"""
|
|
||||||
async with self._start_lock:
|
|
||||||
if not self._is_running:
|
|
||||||
await self._start_queue_processor()
|
|
||||||
|
|
||||||
future = asyncio.Future()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
self._command_queue.put((data, expected_events, timeout, future)),
|
|
||||||
timeout=1.0
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
future.set_exception(RuntimeError(
|
|
||||||
f"Command queue is full ({self._command_queue.maxsize} commands pending)"
|
|
||||||
))
|
|
||||||
except Exception as e:
|
|
||||||
future.set_exception(e)
|
|
||||||
|
|
||||||
return await future
|
|
||||||
|
|
||||||
async def _send_internal(
|
|
||||||
self,
|
|
||||||
data: bytes,
|
|
||||||
expected_events: Optional[Union[EventType, List[EventType]]] = None,
|
|
||||||
timeout: Optional[float] = None,
|
|
||||||
) -> Event:
|
|
||||||
"""
|
|
||||||
Internal method that does the actual sending and waiting for events.
|
|
||||||
This runs inside the queue processor with lock protection.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: The data to send
|
data: The data to send
|
||||||
@@ -141,6 +96,7 @@ class CommandHandlerBase:
|
|||||||
if not self.dispatcher:
|
if not self.dispatcher:
|
||||||
raise RuntimeError("Dispatcher not set, cannot send commands")
|
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
|
timeout = timeout if timeout is not None else self.default_timeout
|
||||||
|
|
||||||
if self._sender_func:
|
if self._sender_func:
|
||||||
@@ -151,11 +107,13 @@ class CommandHandlerBase:
|
|||||||
|
|
||||||
if expected_events:
|
if expected_events:
|
||||||
try:
|
try:
|
||||||
|
# Convert single event to list if needed
|
||||||
if not isinstance(expected_events, list):
|
if not isinstance(expected_events, list):
|
||||||
expected_events = [expected_events]
|
expected_events = [expected_events]
|
||||||
|
|
||||||
logger.debug(f"Waiting for events {expected_events}, timeout={timeout}")
|
logger.debug(f"Waiting for events {expected_events}, timeout={timeout}")
|
||||||
|
|
||||||
|
# Create futures for all expected events
|
||||||
futures = []
|
futures = []
|
||||||
for event_type in expected_events:
|
for event_type in expected_events:
|
||||||
future = asyncio.create_task(
|
future = asyncio.create_task(
|
||||||
@@ -163,18 +121,22 @@ class CommandHandlerBase:
|
|||||||
)
|
)
|
||||||
futures.append(future)
|
futures.append(future)
|
||||||
|
|
||||||
|
# Wait for the first event to complete or all to timeout
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
futures, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Cancel all pending futures
|
||||||
for future in pending:
|
for future in pending:
|
||||||
future.cancel()
|
future.cancel()
|
||||||
|
|
||||||
|
# Check if any future completed successfully
|
||||||
for future in done:
|
for future in done:
|
||||||
event = await future
|
event = await future
|
||||||
if event:
|
if event:
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
# Create an error event when no event is received
|
||||||
return Event(EventType.ERROR, {"reason": "no_event_received"})
|
return Event(EventType.ERROR, {"reason": "no_event_received"})
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.debug(f"Command timed out {data}")
|
logger.debug(f"Command timed out {data}")
|
||||||
@@ -182,107 +144,26 @@ class CommandHandlerBase:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Command error: {e}")
|
logger.debug(f"Command error: {e}")
|
||||||
return Event(EventType.ERROR, {"error": str(e)})
|
return Event(EventType.ERROR, {"error": str(e)})
|
||||||
|
# For commands that don't expect events, return a success event
|
||||||
return Event(EventType.OK, {})
|
return Event(EventType.OK, {})
|
||||||
|
|
||||||
async def start_queue_processor(self):
|
# attached at base because its a common method
|
||||||
"""
|
async def send_binary_req(self, dst: DestinationType, request_type: BinaryReqType, data: Optional[bytes] = None, timeout=None) -> Event:
|
||||||
Start the command queue processor.
|
dst_bytes = _validate_destination(dst, prefix_length=32)
|
||||||
This should be called once when the connection is established.
|
pubkey_prefix = _validate_destination(dst, prefix_length=6)
|
||||||
"""
|
logger.debug(f"Binary request to {dst_bytes.hex()}")
|
||||||
async with self._start_lock:
|
data = b"\x32" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
|
||||||
if not self._is_running:
|
|
||||||
await self._start_queue_processor()
|
|
||||||
|
|
||||||
async def _start_queue_processor(self):
|
result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
||||||
"""Internal method to start the background queue processor."""
|
|
||||||
if not self._queue_processor_task or self._queue_processor_task.done():
|
|
||||||
self._is_running = True
|
|
||||||
self._queue_processor_task = asyncio.create_task(self._process_queue())
|
|
||||||
logger.debug("Started command queue processor")
|
|
||||||
|
|
||||||
async def _process_queue(self):
|
# Register the request with the reader if we have both reader and request_type
|
||||||
"""Process commands from the queue sequentially."""
|
if (result.type == EventType.MSG_SENT and
|
||||||
logger.debug("Command queue processor started")
|
self._reader is not None and
|
||||||
while self._is_running:
|
request_type is not None):
|
||||||
try:
|
|
||||||
item = await self._command_queue.get()
|
|
||||||
|
|
||||||
# kill queue signal
|
exp_tag = result.payload["expected_ack"].hex()
|
||||||
if item is None:
|
# Use provided timeout or fallback to suggested timeout (with 5s default)
|
||||||
logger.debug("Received shutdown sentinel")
|
actual_timeout = timeout if timeout is not None and timeout > 0 else result.payload.get("suggested_timeout", 4000) / 800.0
|
||||||
break
|
self._reader.register_binary_request(pubkey_prefix.hex(), exp_tag, request_type, actual_timeout)
|
||||||
|
|
||||||
data, expected_events, timeout, future = item
|
return result
|
||||||
|
|
||||||
if future.cancelled():
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug(f"Processing queued command: {data.hex() if isinstance(data, bytes) else data}")
|
|
||||||
result = await self._send_internal(data, expected_events, timeout)
|
|
||||||
|
|
||||||
if not future.cancelled():
|
|
||||||
future.set_result(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing command: {e}")
|
|
||||||
if not future.cancelled():
|
|
||||||
future.set_exception(e)
|
|
||||||
|
|
||||||
# Small delay between commands to avoid overwhelming the device
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.debug("Queue processor cancelled")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Queue processor error: {e}")
|
|
||||||
# Continue processing even if there was an error
|
|
||||||
|
|
||||||
logger.debug("Command queue processor stopped")
|
|
||||||
|
|
||||||
async def stop_queue_processor(self):
|
|
||||||
"""Stop the queue processor gracefully."""
|
|
||||||
logger.debug("Stopping command queue processor")
|
|
||||||
|
|
||||||
if not self._is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._is_running = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# send kill signal and wait for it to be processed
|
|
||||||
await asyncio.wait_for(self._command_queue.put(None), timeout=1.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Could not send shutdown sentinel (queue may be full)")
|
|
||||||
|
|
||||||
if self._queue_processor_task:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._queue_processor_task, timeout=2.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Queue processor did not stop gracefully, cancelling")
|
|
||||||
self._queue_processor_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._queue_processor_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._queue_processor_task = None
|
|
||||||
|
|
||||||
cancelled_count = 0
|
|
||||||
while not self._command_queue.empty():
|
|
||||||
try:
|
|
||||||
item = self._command_queue.get_nowait()
|
|
||||||
if item is None:
|
|
||||||
continue
|
|
||||||
if isinstance(item, tuple) and len(item) == 4:
|
|
||||||
_, _, _, future = item
|
|
||||||
if not future.cancelled():
|
|
||||||
future.cancel()
|
|
||||||
cancelled_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error during cleanup: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if cancelled_count > 0:
|
|
||||||
logger.debug(f"Cancelled {cancelled_count} pending commands")
|
|
||||||
|
|
||||||
logger.debug("Command queue processor stopped")
|
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import logging
|
import logging
|
||||||
from mailbox import Message
|
|
||||||
|
|
||||||
from meshcore.commands.messaging import MessagingCommands
|
|
||||||
from .base import CommandHandlerBase
|
from .base import CommandHandlerBase
|
||||||
from ..events import EventType
|
from ..events import EventType
|
||||||
from ..binary_parsing import BinaryReqType, lpp_parse, lpp_parse_mma, parse_acl
|
from ..packets import BinaryReqType
|
||||||
|
|
||||||
logger = logging.getLogger("meshcore")
|
logger = logging.getLogger("meshcore")
|
||||||
|
|
||||||
|
|
||||||
class BinaryCommandHandler(MessagingCommands):
|
class BinaryCommandHandler(CommandHandlerBase):
|
||||||
"""Helper functions to handle binary requests through binary commands"""
|
"""Helper functions to handle binary requests through binary commands"""
|
||||||
|
|
||||||
|
|
||||||
async def req_status(self, contact, timeout=0):
|
async def req_status(self, contact, timeout=0):
|
||||||
res = await self.send_binary_req(contact, BinaryReqType.STATUS.value.to_bytes(1, "little"))
|
res = await self.send_binary_req(
|
||||||
|
contact,
|
||||||
|
BinaryReqType.STATUS,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
if res.type == EventType.ERROR:
|
if res.type == EventType.ERROR:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -24,18 +25,20 @@ class BinaryCommandHandler(MessagingCommands):
|
|||||||
if self.dispatcher is None:
|
if self.dispatcher is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Listen for STATUS_RESPONSE event with matching pubkey
|
|
||||||
contact_pubkey_prefix = contact["public_key"][0:12]
|
|
||||||
status_event = await self.dispatcher.wait_for_event(
|
status_event = await self.dispatcher.wait_for_event(
|
||||||
EventType.STATUS_RESPONSE,
|
EventType.STATUS_RESPONSE,
|
||||||
attribute_filters={"pubkey_prefix": contact_pubkey_prefix},
|
attribute_filters={"tag": exp_tag},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
return status_event.payload if status_event else None
|
return status_event.payload if status_event else None
|
||||||
|
|
||||||
async def req_telemetry(self, contact, timeout=0):
|
async def req_telemetry(self, contact, timeout=0):
|
||||||
res = await self.send_binary_req(contact, BinaryReqType.TELEMETRY.value.to_bytes(1, "little"))
|
res = await self.send_binary_req(
|
||||||
|
contact,
|
||||||
|
BinaryReqType.TELEMETRY,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
if res.type == EventType.ERROR:
|
if res.type == EventType.ERROR:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -44,11 +47,10 @@ class BinaryCommandHandler(MessagingCommands):
|
|||||||
if self.dispatcher is None:
|
if self.dispatcher is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Listen for TELEMETRY_RESPONSE event with matching pubkey
|
# Listen for TELEMETRY_RESPONSE event
|
||||||
contact_pubkey_prefix = contact["public_key"][0:12]
|
|
||||||
telem_event = await self.dispatcher.wait_for_event(
|
telem_event = await self.dispatcher.wait_for_event(
|
||||||
EventType.TELEMETRY_RESPONSE,
|
EventType.TELEMETRY_RESPONSE,
|
||||||
attribute_filters={"pubkey_prefix": contact_pubkey_prefix},
|
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,12 +58,16 @@ class BinaryCommandHandler(MessagingCommands):
|
|||||||
|
|
||||||
async def req_mma(self, contact, start, end, timeout=0):
|
async def req_mma(self, contact, start, end, timeout=0):
|
||||||
req = (
|
req = (
|
||||||
BinaryReqType.MMA.value.to_bytes(1, "little", signed=False)
|
start.to_bytes(4, "little", signed=False)
|
||||||
+ start.to_bytes(4, "little", signed=False)
|
|
||||||
+ end.to_bytes(4, "little", signed=False)
|
+ end.to_bytes(4, "little", signed=False)
|
||||||
+ b"\0\0"
|
+ b"\0\0"
|
||||||
)
|
)
|
||||||
res = await self.send_binary_req(contact, req)
|
res = await self.send_binary_req(
|
||||||
|
contact,
|
||||||
|
BinaryReqType.MMA,
|
||||||
|
data=req,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
if res.type == EventType.ERROR:
|
if res.type == EventType.ERROR:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -70,19 +76,23 @@ class BinaryCommandHandler(MessagingCommands):
|
|||||||
if self.dispatcher is None:
|
if self.dispatcher is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Listen for MMA_RESPONSE event with matching pubkey
|
# Listen for MMA_RESPONSE
|
||||||
contact_pubkey_prefix = contact["public_key"][0:12]
|
|
||||||
mma_event = await self.dispatcher.wait_for_event(
|
mma_event = await self.dispatcher.wait_for_event(
|
||||||
EventType.MMA_RESPONSE,
|
EventType.MMA_RESPONSE,
|
||||||
attribute_filters={"pubkey_prefix": contact_pubkey_prefix},
|
attribute_filters={"tag": res.payload["expected_ack"].hex()},
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
return mma_event.payload["mma_data"] if mma_event else None
|
return mma_event.payload["mma_data"] if mma_event else None
|
||||||
|
|
||||||
async def req_acl(self, contact, timeout=0):
|
async def req_acl(self, contact, timeout=0):
|
||||||
req = BinaryReqType.ACL.value.to_bytes(1, "little", signed=False) + b"\0\0"
|
req = b"\0\0"
|
||||||
res = await self.send_binary_req(contact, req)
|
res = await self.send_binary_req(
|
||||||
|
contact,
|
||||||
|
BinaryReqType.ACL,
|
||||||
|
data=req,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
if res.type == EventType.ERROR:
|
if res.type == EventType.ERROR:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ class MessagingCommands(CommandHandlerBase):
|
|||||||
data = b"\x27\x00\x00\x00" + dst_bytes
|
data = b"\x27\x00\x00\x00" + dst_bytes
|
||||||
return await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
|
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:
|
async def send_path_discovery(self, dst: DestinationType) -> Event:
|
||||||
dst_bytes = _validate_destination(dst, prefix_length=32)
|
dst_bytes = _validate_destination(dst, prefix_length=32)
|
||||||
logger.debug(f"Path discovery request for {dst_bytes.hex()}")
|
logger.debug(f"Path discovery request for {dst_bytes.hex()}")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ConnectionProtocol(Protocol):
|
|||||||
"""Disconnect from the device/server."""
|
"""Disconnect from the device/server."""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def send(self, data) -> Any:
|
async def send(self, data):
|
||||||
"""Send data through the connection."""
|
"""Send data through the connection."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from collections.abc import Coroutine
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
@@ -116,7 +115,7 @@ class EventDispatcher:
|
|||||||
def subscribe(
|
def subscribe(
|
||||||
self,
|
self,
|
||||||
event_type: Union[EventType, None],
|
event_type: Union[EventType, None],
|
||||||
callback: Callable[[Event], Coroutine[Any, Any, None]],
|
callback: Callable[[Event], Union[None, asyncio.Future]],
|
||||||
attribute_filters: Optional[Dict[str, Any]] = None,
|
attribute_filters: Optional[Dict[str, Any]] = None,
|
||||||
) -> Subscription:
|
) -> Subscription:
|
||||||
"""
|
"""
|
||||||
@@ -229,7 +228,7 @@ class EventDispatcher:
|
|||||||
"""
|
"""
|
||||||
future = asyncio.Future()
|
future = asyncio.Future()
|
||||||
|
|
||||||
async def event_handler(event: Event):
|
def event_handler(event: Event):
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_result(event)
|
future.set_result(event)
|
||||||
|
|
||||||
|
|||||||
@@ -162,10 +162,6 @@ class MeshCore:
|
|||||||
result = await self.connection_manager.connect()
|
result = await self.connection_manager.connect()
|
||||||
if result is None:
|
if result is None:
|
||||||
raise ConnectionError("Failed to connect to device")
|
raise ConnectionError("Failed to connect to device")
|
||||||
|
|
||||||
# Start the command queue processor after successful connection
|
|
||||||
await self.commands.start_queue_processor()
|
|
||||||
|
|
||||||
return await self.commands.send_appstart()
|
return await self.commands.send_appstart()
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self):
|
||||||
@@ -177,9 +173,6 @@ class MeshCore:
|
|||||||
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()
|
||||||
|
|
||||||
# Stop the command queue processor
|
|
||||||
await self.commands.stop_queue_processor()
|
|
||||||
|
|
||||||
# Disconnect the connection object
|
# Disconnect the connection object
|
||||||
await self.connection_manager.disconnect()
|
await self.connection_manager.disconnect()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
class BinaryReqType(Enum):
|
||||||
|
STATUS = 0x01
|
||||||
|
KEEP_ALIVE = 0x02
|
||||||
|
TELEMETRY = 0x03
|
||||||
|
MMA = 0x04
|
||||||
|
ACL = 0x05
|
||||||
|
|
||||||
# Packet prefixes for the protocol
|
# Packet prefixes for the protocol
|
||||||
class PacketType(Enum):
|
class PacketType(Enum):
|
||||||
|
|||||||
109
src/meshcore/parsing.py
Normal file
109
src/meshcore/parsing.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
import json
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if lpp_type is None:
|
||||||
|
logger.error(f"Unknown LPP type: {type}")
|
||||||
|
return None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def parse_status(data, pubkey_prefix=None, offset=0):
|
||||||
|
"""
|
||||||
|
Parse binary data into a dictionary of fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: bytes object containing the data to parse
|
||||||
|
pubkey_prefix: Either a string prefix or None (if None, extract from data)
|
||||||
|
offset: Starting offset for field parsing (0 or 8)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with parsed fields
|
||||||
|
"""
|
||||||
|
res = {}
|
||||||
|
|
||||||
|
# Handle pubkey
|
||||||
|
if pubkey_prefix is None:
|
||||||
|
# Extract from data (format 1)
|
||||||
|
res["pubkey_pre"] = data[2:8].hex()
|
||||||
|
offset = 8 # Fields start at offset 8
|
||||||
|
else:
|
||||||
|
# Use provided prefix (format 2)
|
||||||
|
res["pubkey_pre"] = pubkey_prefix
|
||||||
|
# offset stays as provided (typically 0)
|
||||||
|
|
||||||
|
# Parse all fields with the given offset
|
||||||
|
res["bat"] = int.from_bytes(data[offset:offset+2], byteorder="little")
|
||||||
|
res["tx_queue_len"] = int.from_bytes(data[offset+2:offset+4], byteorder="little")
|
||||||
|
res["noise_floor"] = int.from_bytes(data[offset+4:offset+6], byteorder="little", signed=True)
|
||||||
|
res["last_rssi"] = int.from_bytes(data[offset+6:offset+8], byteorder="little", signed=True)
|
||||||
|
res["nb_recv"] = int.from_bytes(data[offset+8:offset+12], byteorder="little", signed=False)
|
||||||
|
res["nb_sent"] = int.from_bytes(data[offset+12:offset+16], byteorder="little", signed=False)
|
||||||
|
res["airtime"] = int.from_bytes(data[offset+16:offset+20], byteorder="little")
|
||||||
|
res["uptime"] = int.from_bytes(data[offset+20:offset+24], byteorder="little")
|
||||||
|
res["sent_flood"] = int.from_bytes(data[offset+24:offset+28], byteorder="little")
|
||||||
|
res["sent_direct"] = int.from_bytes(data[offset+28:offset+32], byteorder="little")
|
||||||
|
res["recv_flood"] = int.from_bytes(data[offset+32:offset+36], byteorder="little")
|
||||||
|
res["recv_direct"] = int.from_bytes(data[offset+36:offset+40], byteorder="little")
|
||||||
|
res["full_evts"] = int.from_bytes(data[offset+40:offset+42], byteorder="little")
|
||||||
|
res["last_snr"] = int.from_bytes(data[offset+42:offset+44], byteorder="little", signed=True) / 4
|
||||||
|
res["direct_dups"] = int.from_bytes(data[offset+44:offset+46], byteorder="little")
|
||||||
|
res["flood_dups"] = int.from_bytes(data[offset+46:offset+48], byteorder="little")
|
||||||
|
res["rx_airtime"] = int.from_bytes(data[offset+48:offset+52], byteorder="little")
|
||||||
|
|
||||||
|
return res
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from typing import Any, 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 BinaryReqType, PacketType
|
||||||
from .binary_parsing import BinaryReqType, lpp_parse, lpp_parse_mma, parse_acl
|
from .parsing import lpp_parse, lpp_parse_mma, parse_acl, parse_status
|
||||||
from cayennelpp import LppFrame, LppData
|
from cayennelpp import LppFrame, LppData
|
||||||
from meshcore.lpp_json_encoder import lpp_json_encoder
|
from meshcore.lpp_json_encoder import lpp_json_encoder
|
||||||
|
|
||||||
@@ -18,6 +19,34 @@ class MessageReader:
|
|||||||
self.contacts = {} # Temporary storage during contact list building
|
self.contacts = {} # Temporary storage during contact list building
|
||||||
self.contact_nb = 0 # Used for contact processing
|
self.contact_nb = 0 # Used for contact processing
|
||||||
|
|
||||||
|
# Track pending binary requests by tag for proper response parsing
|
||||||
|
self.pending_binary_requests: Dict[str, Dict[str, Any]] = {} # tag -> {request_type, expires_at}
|
||||||
|
|
||||||
|
def register_binary_request(self, prefix: str, tag: str, request_type: BinaryReqType, timeout_seconds: float):
|
||||||
|
"""Register a pending binary request for proper response parsing"""
|
||||||
|
# Clean up expired requests before adding new one
|
||||||
|
self.cleanup_expired_requests()
|
||||||
|
|
||||||
|
expires_at = time.time() + timeout_seconds
|
||||||
|
self.pending_binary_requests[tag] = {
|
||||||
|
"request_type": request_type,
|
||||||
|
"pubkey_prefix": prefix,
|
||||||
|
"expires_at": expires_at
|
||||||
|
}
|
||||||
|
logger.debug(f"Registered binary request: tag={tag}, type={request_type}, expires in {timeout_seconds}s")
|
||||||
|
|
||||||
|
def cleanup_expired_requests(self):
|
||||||
|
"""Remove expired binary requests"""
|
||||||
|
current_time = time.time()
|
||||||
|
expired_tags = [
|
||||||
|
tag for tag, info in self.pending_binary_requests.items()
|
||||||
|
if current_time > info["expires_at"]
|
||||||
|
]
|
||||||
|
|
||||||
|
for tag in expired_tags:
|
||||||
|
logger.debug(f"Cleaning up expired binary request: tag={tag}")
|
||||||
|
del self.pending_binary_requests[tag]
|
||||||
|
|
||||||
async def handle_rx(self, data: bytearray):
|
async def handle_rx(self, data: bytearray):
|
||||||
packet_type_value = data[0]
|
packet_type_value = data[0]
|
||||||
logger.debug(f"Received data: {data.hex()}")
|
logger.debug(f"Received data: {data.hex()}")
|
||||||
@@ -331,36 +360,13 @@ class MessageReader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif packet_type_value == PacketType.STATUS_RESPONSE.value:
|
elif packet_type_value == PacketType.STATUS_RESPONSE.value:
|
||||||
res = {}
|
res = parse_status(data, offset=8)
|
||||||
res["pubkey_pre"] = data[2:8].hex()
|
data_hex = data[8:].hex()
|
||||||
res["bat"] = int.from_bytes(data[8:10], byteorder="little")
|
logger.debug(f"Status response: {data_hex}")
|
||||||
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["last_rssi"] = int.from_bytes(
|
|
||||||
data[14:16], 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["airtime"] = int.from_bytes(data[24:28], byteorder="little")
|
|
||||||
res["uptime"] = int.from_bytes(data[28:32], 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")
|
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
"pubkey_prefix": res["pubkey_pre"],
|
||||||
|
}
|
||||||
data_hex = data[8:].hex()
|
data_hex = data[8:].hex()
|
||||||
logger.debug(f"Status response: {data_hex}")
|
logger.debug(f"Status response: {data_hex}")
|
||||||
|
|
||||||
@@ -491,112 +497,60 @@ class MessageReader:
|
|||||||
|
|
||||||
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()}")
|
||||||
res = {}
|
tag = data[2:6].hex()
|
||||||
|
|
||||||
res["tag"] = data[2:6].hex()
|
|
||||||
res["data"] = data[6:].hex()
|
|
||||||
|
|
||||||
attributes = {"tag": res["tag"]}
|
|
||||||
|
|
||||||
# Always dispatch the generic BINARY_RESPONSE event first
|
|
||||||
await self.dispatcher.dispatch(
|
|
||||||
Event(EventType.BINARY_RESPONSE, res, attributes)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse the request type from the response data and dispatch specific events
|
|
||||||
response_data = data[6:]
|
response_data = data[6:]
|
||||||
if response_data: # Check if there's response data
|
|
||||||
request_type = response_data[0]
|
|
||||||
|
|
||||||
if request_type == BinaryReqType.STATUS.value:
|
# Always dispatch generic BINARY_RESPONSE
|
||||||
# Parse as status response - use same parsing as STATUS_RESPONSE
|
binary_res = {"tag": tag, "data": response_data.hex()}
|
||||||
if len(response_data) >= 53: # Minimum size for status data
|
|
||||||
status_res = {}
|
|
||||||
status_res["pubkey_pre"] = data[2:8].hex() # Use pubkey from tag area
|
|
||||||
status_data = response_data[1:] # Skip the request type byte
|
|
||||||
|
|
||||||
status_res["bat"] = int.from_bytes(status_data[0:2], byteorder="little")
|
|
||||||
status_res["tx_queue_len"] = int.from_bytes(status_data[2:4], byteorder="little")
|
|
||||||
status_res["noise_floor"] = int.from_bytes(status_data[4:6], byteorder="little", signed=True)
|
|
||||||
status_res["last_rssi"] = int.from_bytes(status_data[6:8], byteorder="little", signed=True)
|
|
||||||
status_res["nb_recv"] = int.from_bytes(status_data[8:12], byteorder="little", signed=False)
|
|
||||||
status_res["nb_sent"] = int.from_bytes(status_data[12:16], byteorder="little", signed=False)
|
|
||||||
status_res["airtime"] = int.from_bytes(status_data[16:20], byteorder="little")
|
|
||||||
status_res["uptime"] = int.from_bytes(status_data[20:24], byteorder="little")
|
|
||||||
status_res["sent_flood"] = int.from_bytes(status_data[24:28], byteorder="little")
|
|
||||||
status_res["sent_direct"] = int.from_bytes(status_data[28:32], byteorder="little")
|
|
||||||
status_res["recv_flood"] = int.from_bytes(status_data[32:36], byteorder="little")
|
|
||||||
status_res["recv_direct"] = int.from_bytes(status_data[36:40], byteorder="little")
|
|
||||||
status_res["full_evts"] = int.from_bytes(status_data[40:42], byteorder="little")
|
|
||||||
status_res["last_snr"] = int.from_bytes(status_data[42:44], byteorder="little", signed=True) / 4
|
|
||||||
status_res["direct_dups"] = int.from_bytes(status_data[44:46], byteorder="little")
|
|
||||||
status_res["flood_dups"] = int.from_bytes(status_data[46:48], byteorder="little")
|
|
||||||
status_res["rx_airtime"] = int.from_bytes(status_data[48:52], byteorder="little")
|
|
||||||
|
|
||||||
status_attributes = {"pubkey_prefix": status_res["pubkey_pre"]}
|
|
||||||
await self.dispatcher.dispatch(
|
await self.dispatcher.dispatch(
|
||||||
Event(EventType.STATUS_RESPONSE, status_res, status_attributes)
|
Event(EventType.BINARY_RESPONSE, binary_res, {"tag": tag})
|
||||||
)
|
)
|
||||||
|
|
||||||
elif request_type == BinaryReqType.TELEMETRY.value:
|
# Check for tracked request type and dispatch specific response
|
||||||
# Parse as telemetry response
|
if tag in self.pending_binary_requests:
|
||||||
try:
|
request_type = self.pending_binary_requests[tag]["request_type"]
|
||||||
telemetry_data = response_data[1:] # Skip the request type byte
|
pubkey_prefix = self.pending_binary_requests[tag]["pubkey_prefix"]
|
||||||
lpp = lpp_parse(telemetry_data)
|
del self.pending_binary_requests[tag]
|
||||||
|
logger.debug(f"Processing binary response for tag {tag}, type {request_type}, pubkey_prefix {pubkey_prefix}")
|
||||||
telem_res = {
|
|
||||||
"pubkey_pre": data[2:8].hex(),
|
|
||||||
"lpp": lpp
|
|
||||||
}
|
|
||||||
|
|
||||||
telem_attributes = {
|
|
||||||
"raw": telemetry_data.hex(),
|
|
||||||
"pubkey_prefix": telem_res["pubkey_pre"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if request_type == BinaryReqType.STATUS and len(response_data) >= 52:
|
||||||
|
res = {}
|
||||||
|
res = parse_status(response_data, pubkey_prefix=pubkey_prefix)
|
||||||
await self.dispatcher.dispatch(
|
await self.dispatcher.dispatch(
|
||||||
Event(EventType.TELEMETRY_RESPONSE, telem_res, telem_attributes)
|
Event(EventType.STATUS_RESPONSE, res, {"pubkey_prefix": res["pubkey_pre"], "tag": tag})
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request_type == BinaryReqType.TELEMETRY:
|
||||||
|
try:
|
||||||
|
lpp = lpp_parse(response_data)
|
||||||
|
telem_res = {"tag": tag, "lpp": lpp, "pubkey_prefix": pubkey_prefix}
|
||||||
|
await self.dispatcher.dispatch(
|
||||||
|
Event(EventType.TELEMETRY_RESPONSE, telem_res, telem_res)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing binary telemetry response: {e}")
|
logger.error(f"Error parsing binary telemetry response: {e}")
|
||||||
|
|
||||||
elif request_type == BinaryReqType.MMA.value:
|
elif request_type == BinaryReqType.MMA:
|
||||||
# Parse as MMA response
|
|
||||||
try:
|
try:
|
||||||
mma_data = response_data[5:] # Skip request type + 4 bytes header
|
mma_result = lpp_parse_mma(response_data[4:]) # Skip 4-byte header
|
||||||
mma_result = lpp_parse_mma(mma_data)
|
mma_res = {"tag": tag, "mma_data": mma_result, "pubkey_prefix": pubkey_prefix}
|
||||||
|
|
||||||
mma_res = {
|
|
||||||
"pubkey_pre": data[2:8].hex(),
|
|
||||||
"mma_data": mma_result
|
|
||||||
}
|
|
||||||
|
|
||||||
mma_attributes = {"pubkey_prefix": mma_res["pubkey_pre"]}
|
|
||||||
|
|
||||||
await self.dispatcher.dispatch(
|
await self.dispatcher.dispatch(
|
||||||
Event(EventType.MMA_RESPONSE, mma_res, mma_attributes)
|
Event(EventType.MMA_RESPONSE, mma_res, mma_res)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing binary MMA response: {e}")
|
logger.error(f"Error parsing binary MMA response: {e}")
|
||||||
|
|
||||||
elif request_type == BinaryReqType.ACL.value:
|
elif request_type == BinaryReqType.ACL:
|
||||||
# Parse as ACL response
|
|
||||||
try:
|
try:
|
||||||
acl_data = response_data[1:] # Skip the request type byte
|
acl_result = parse_acl(response_data)
|
||||||
acl_result = parse_acl(acl_data)
|
acl_res = {"tag": tag, "acl_data": acl_result, "pubkey_prefix": pubkey_prefix}
|
||||||
|
|
||||||
acl_res = {
|
|
||||||
"pubkey_pre": data[2:8].hex(),
|
|
||||||
"acl_data": acl_result
|
|
||||||
}
|
|
||||||
|
|
||||||
acl_attributes = {"pubkey_prefix": acl_res["pubkey_pre"]}
|
|
||||||
|
|
||||||
await self.dispatcher.dispatch(
|
await self.dispatcher.dispatch(
|
||||||
Event(EventType.ACL_RESPONSE, acl_res, acl_attributes)
|
Event(EventType.ACL_RESPONSE, acl_res, {"tag": tag, "pubkey_prefix": pubkey_prefix})
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing binary ACL response: {e}")
|
logger.error(f"Error parsing binary ACL response: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"No tracked request found for binary response tag {tag}")
|
||||||
|
|
||||||
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()}")
|
||||||
|
|||||||
89
tests/unit/test_reader.py
Normal file
89
tests/unit/test_reader.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from meshcore.events import EventType
|
||||||
|
from meshcore.reader import MessageReader
|
||||||
|
|
||||||
|
class MockDispatcher:
|
||||||
|
def __init__(self):
|
||||||
|
self.dispatched_events = []
|
||||||
|
|
||||||
|
async def dispatch(self, event):
|
||||||
|
self.dispatched_events.append(event)
|
||||||
|
print(f"Dispatched: {event.type} with payload keys: {list(event.payload.keys()) if hasattr(event.payload, 'keys') else event.payload}")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_binary_response():
|
||||||
|
mock_dispatcher = MockDispatcher()
|
||||||
|
reader = MessageReader(mock_dispatcher)
|
||||||
|
|
||||||
|
packet_hex = "8c00417db968993acd42fc77c3bbd1f08b9b84c39756410c58cd03077162bcb489031869586ab4b103000000000000000000"
|
||||||
|
packet_data = bytearray.fromhex(packet_hex)
|
||||||
|
|
||||||
|
print(f"Testing packet: {packet_hex}")
|
||||||
|
print(f"Packet type: 0x{packet_data[0]:02x} (should be 0x8c for BINARY_RESPONSE)")
|
||||||
|
|
||||||
|
# Register the binary request first
|
||||||
|
tag = "417db968"
|
||||||
|
from meshcore.parsing import BinaryReqType
|
||||||
|
reader.register_binary_request(tag, BinaryReqType.ACL, 10.0)
|
||||||
|
print(f"Registered ACL request with tag {tag}")
|
||||||
|
|
||||||
|
await reader.handle_rx(packet_data)
|
||||||
|
|
||||||
|
# Check what was dispatched
|
||||||
|
print(f"\nTotal events dispatched: {len(mock_dispatcher.dispatched_events)}")
|
||||||
|
|
||||||
|
# Verify BINARY_RESPONSE was dispatched
|
||||||
|
binary_responses = [e for e in mock_dispatcher.dispatched_events if e.type == EventType.BINARY_RESPONSE]
|
||||||
|
assert len(binary_responses) == 1, f"Expected 1 BINARY_RESPONSE, got {len(binary_responses)}"
|
||||||
|
print("✅ BINARY_RESPONSE event dispatched correctly")
|
||||||
|
|
||||||
|
# Check the binary response payload
|
||||||
|
binary_event = binary_responses[0]
|
||||||
|
assert "tag" in binary_event.payload, "BINARY_RESPONSE should have 'tag' in payload"
|
||||||
|
assert "data" in binary_event.payload, "BINARY_RESPONSE should have 'data' in payload"
|
||||||
|
print(f"✅ Binary response tag: {binary_event.payload['tag']}")
|
||||||
|
print(f"✅ Binary response data: {binary_event.payload['data']}")
|
||||||
|
|
||||||
|
# Check if a specific parsed event was also dispatched
|
||||||
|
other_events = [e for e in mock_dispatcher.dispatched_events if e.type != EventType.BINARY_RESPONSE]
|
||||||
|
if other_events:
|
||||||
|
print(f"✅ Additional parsed event dispatched: {other_events[0].type}")
|
||||||
|
print(f" Payload keys: {list(other_events[0].payload.keys()) if hasattr(other_events[0].payload, 'keys') else other_events[0].payload}")
|
||||||
|
else:
|
||||||
|
print("⚠️ No additional parsed event dispatched")
|
||||||
|
|
||||||
|
# Parse the response data to see what request type it is
|
||||||
|
response_data = packet_data[6:]
|
||||||
|
if response_data:
|
||||||
|
request_type = response_data[0]
|
||||||
|
print(f"Request type in response: 0x{request_type:02x} ({request_type})")
|
||||||
|
|
||||||
|
# Map request types to expected events
|
||||||
|
from meshcore.parsing import BinaryReqType
|
||||||
|
if request_type == BinaryReqType.STATUS.value:
|
||||||
|
expected_event = EventType.STATUS_RESPONSE
|
||||||
|
elif request_type == BinaryReqType.TELEMETRY.value:
|
||||||
|
expected_event = EventType.TELEMETRY_RESPONSE
|
||||||
|
elif request_type == BinaryReqType.MMA.value:
|
||||||
|
expected_event = EventType.MMA_RESPONSE
|
||||||
|
elif request_type == BinaryReqType.ACL.value:
|
||||||
|
expected_event = EventType.ACL_RESPONSE
|
||||||
|
else:
|
||||||
|
expected_event = None
|
||||||
|
|
||||||
|
if expected_event:
|
||||||
|
specific_events = [e for e in mock_dispatcher.dispatched_events if e.type == expected_event]
|
||||||
|
if specific_events:
|
||||||
|
print(f"✅ Expected {expected_event} event was dispatched")
|
||||||
|
else:
|
||||||
|
print(f"❌ Expected {expected_event} event was NOT dispatched")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unknown request type {request_type}, no specific event expected")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_binary_response())
|
||||||
Reference in New Issue
Block a user