mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-11 03:13:00 +02:00
Implement Phase 2: Interface Component
This commit adds the complete Interface component for MeshCore device communication: Device abstraction (interface/device.py): - BaseMeshCoreDevice abstract class - MeshCoreDevice for real hardware (placeholder for meshcore_py) - DeviceConfig for connection settings - EventType enumeration for all MeshCore events - Event handler registration and dispatching Mock device (interface/mock_device.py): - MockMeshCoreDevice for testing without hardware - Configurable event generation - Simulated network with multiple mock nodes - Support for injecting custom events RECEIVER mode (interface/receiver.py): - Subscribes to device events - Publishes events to MQTT broker - Signal handling for graceful shutdown SENDER mode (interface/sender.py): - Subscribes to MQTT command topics - Dispatches commands to MeshCore device - Handles send_msg, send_channel_msg, send_advert, etc. CLI (interface/cli.py): - Click commands for running interface - Convenience commands for receiver/sender modes - Environment variable support for all options Tests: - Device abstraction tests - Mock device tests - Receiver and sender mode tests
This commit is contained in:
@@ -29,81 +29,10 @@ def cli(ctx: click.Context, log_level: str) -> None:
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
|
||||
@cli.group()
|
||||
def interface() -> None:
|
||||
"""Interface component for MeshCore device communication.
|
||||
# Import and register interface CLI
|
||||
from meshcore_hub.interface.cli import interface
|
||||
|
||||
Runs in RECEIVER or SENDER mode to bridge between
|
||||
MeshCore devices and MQTT broker.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@interface.command("run")
|
||||
@click.option(
|
||||
"--mode",
|
||||
type=click.Choice(["RECEIVER", "SENDER"]),
|
||||
required=True,
|
||||
envvar="INTERFACE_MODE",
|
||||
help="Interface mode: RECEIVER or SENDER",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
def interface_run(
|
||||
mode: str,
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
"""Run the interface component."""
|
||||
click.echo(f"Starting interface in {mode} mode...")
|
||||
click.echo(f"Serial port: {port} (baud: {baud})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo("Interface component not yet implemented.")
|
||||
cli.add_command(interface)
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""CLI for the Interface component."""
|
||||
|
||||
import click
|
||||
|
||||
from meshcore_hub.common.config import InterfaceMode
|
||||
from meshcore_hub.common.logging import configure_logging
|
||||
|
||||
|
||||
@click.group()
|
||||
def interface() -> None:
|
||||
"""Interface component for MeshCore device communication.
|
||||
|
||||
Runs in RECEIVER or SENDER mode to bridge between
|
||||
MeshCore devices and MQTT broker.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@interface.command("run")
|
||||
@click.option(
|
||||
"--mode",
|
||||
type=click.Choice(["RECEIVER", "SENDER"], case_sensitive=False),
|
||||
required=True,
|
||||
envvar="INTERFACE_MODE",
|
||||
help="Interface mode: RECEIVER or SENDER",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-username",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_USERNAME",
|
||||
help="MQTT username",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-password",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MQTT_PASSWORD",
|
||||
help="MQTT password",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
default="INFO",
|
||||
envvar="LOG_LEVEL",
|
||||
help="Log level",
|
||||
)
|
||||
def run(
|
||||
mode: str,
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
mqtt_username: str | None,
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Run the interface component.
|
||||
|
||||
The interface bridges MeshCore devices to an MQTT broker.
|
||||
|
||||
In RECEIVER mode:
|
||||
- Connects to a MeshCore device
|
||||
- Subscribes to device events
|
||||
- Publishes events to MQTT
|
||||
|
||||
In SENDER mode:
|
||||
- Connects to a MeshCore device
|
||||
- Subscribes to MQTT command topics
|
||||
- Executes commands on the device
|
||||
"""
|
||||
configure_logging(level=log_level)
|
||||
|
||||
click.echo(f"Starting interface in {mode} mode")
|
||||
click.echo(f"Serial: {port} @ {baud} baud")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
|
||||
mode_upper = mode.upper()
|
||||
|
||||
if mode_upper == "RECEIVER":
|
||||
from meshcore_hub.interface.receiver import run_receiver
|
||||
|
||||
run_receiver(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
elif mode_upper == "SENDER":
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
|
||||
run_sender(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
else:
|
||||
click.echo(f"Unknown mode: {mode}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@interface.command("receiver")
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
def receiver(
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
"""Run interface in RECEIVER mode.
|
||||
|
||||
Shortcut for: meshcore-hub interface run --mode RECEIVER
|
||||
"""
|
||||
from meshcore_hub.interface.receiver import run_receiver
|
||||
|
||||
click.echo("Starting interface in RECEIVER mode")
|
||||
click.echo(f"Serial: {port} @ {baud} baud")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
|
||||
run_receiver(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
|
||||
|
||||
@interface.command("sender")
|
||||
@click.option(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyUSB0",
|
||||
envvar="SERIAL_PORT",
|
||||
help="Serial port path",
|
||||
)
|
||||
@click.option(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
envvar="SERIAL_BAUD",
|
||||
help="Serial baud rate",
|
||||
)
|
||||
@click.option(
|
||||
"--mock",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
envvar="MOCK_DEVICE",
|
||||
help="Use mock device for testing",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
envvar="MQTT_HOST",
|
||||
help="MQTT broker host",
|
||||
)
|
||||
@click.option(
|
||||
"--mqtt-port",
|
||||
type=int,
|
||||
default=1883,
|
||||
envvar="MQTT_PORT",
|
||||
help="MQTT broker port",
|
||||
)
|
||||
@click.option(
|
||||
"--prefix",
|
||||
type=str,
|
||||
default="meshcore",
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
def sender(
|
||||
port: str,
|
||||
baud: int,
|
||||
mock: bool,
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
"""Run interface in SENDER mode.
|
||||
|
||||
Shortcut for: meshcore-hub interface run --mode SENDER
|
||||
"""
|
||||
from meshcore_hub.interface.sender import run_sender
|
||||
|
||||
click.echo("Starting interface in SENDER mode")
|
||||
click.echo(f"Serial: {port} @ {baud} baud")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Mock device: {mock}")
|
||||
|
||||
run_sender(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_prefix=prefix,
|
||||
)
|
||||
@@ -0,0 +1,393 @@
|
||||
"""MeshCore device wrapper for serial communication."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
"""MeshCore event types."""
|
||||
|
||||
ADVERTISEMENT = "advertisement"
|
||||
CONTACT_MSG_RECV = "contact_msg_recv"
|
||||
CHANNEL_MSG_RECV = "channel_msg_recv"
|
||||
TRACE_DATA = "trace_data"
|
||||
TELEMETRY_RESPONSE = "telemetry_response"
|
||||
CONTACTS = "contacts"
|
||||
SEND_CONFIRMED = "send_confirmed"
|
||||
STATUS_RESPONSE = "status_response"
|
||||
BATTERY = "battery"
|
||||
PATH_UPDATED = "path_updated"
|
||||
|
||||
|
||||
EventHandler = Callable[[EventType, dict[str, Any]], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConfig:
|
||||
"""Device connection configuration."""
|
||||
|
||||
port: str = "/dev/ttyUSB0"
|
||||
baud: int = 115200
|
||||
timeout: float = 1.0
|
||||
reconnect_delay: float = 5.0
|
||||
max_reconnect_attempts: int = 10
|
||||
|
||||
|
||||
class BaseMeshCoreDevice(ABC):
|
||||
"""Abstract base class for MeshCore device interface."""
|
||||
|
||||
def __init__(self, config: DeviceConfig):
|
||||
"""Initialize device.
|
||||
|
||||
Args:
|
||||
config: Device configuration
|
||||
"""
|
||||
self.config = config
|
||||
self._connected = False
|
||||
self._public_key: Optional[str] = None
|
||||
self._event_handlers: dict[EventType, list[EventHandler]] = {}
|
||||
|
||||
@property
|
||||
def public_key(self) -> Optional[str]:
|
||||
"""Get the device's public key."""
|
||||
return self._public_key
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if device is connected."""
|
||||
return self._connected
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the device.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_message(
|
||||
self,
|
||||
destination: str,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a direct message.
|
||||
|
||||
Args:
|
||||
destination: Destination public key or prefix
|
||||
text: Message content
|
||||
timestamp: Optional timestamp (defaults to current time)
|
||||
|
||||
Returns:
|
||||
True if message was queued successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_channel_message(
|
||||
self,
|
||||
channel_idx: int,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a channel message.
|
||||
|
||||
Args:
|
||||
channel_idx: Channel index (0-255)
|
||||
text: Message content
|
||||
timestamp: Optional timestamp (defaults to current time)
|
||||
|
||||
Returns:
|
||||
True if message was queued successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_advertisement(self, flood: bool = True) -> bool:
|
||||
"""Send a node advertisement.
|
||||
|
||||
Args:
|
||||
flood: Whether to flood the advertisement
|
||||
|
||||
Returns:
|
||||
True if advertisement was queued successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def request_status(self, target: Optional[str] = None) -> bool:
|
||||
"""Request status from a node.
|
||||
|
||||
Args:
|
||||
target: Target node public key (optional)
|
||||
|
||||
Returns:
|
||||
True if request was sent
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def request_telemetry(self, target: str) -> bool:
|
||||
"""Request telemetry from a node.
|
||||
|
||||
Args:
|
||||
target: Target node public key
|
||||
|
||||
Returns:
|
||||
True if request was sent
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop (blocking)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""Stop the device event loop."""
|
||||
pass
|
||||
|
||||
def register_handler(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: EventHandler,
|
||||
) -> None:
|
||||
"""Register an event handler.
|
||||
|
||||
Args:
|
||||
event_type: Event type to handle
|
||||
handler: Handler function
|
||||
"""
|
||||
if event_type not in self._event_handlers:
|
||||
self._event_handlers[event_type] = []
|
||||
self._event_handlers[event_type].append(handler)
|
||||
logger.debug(f"Registered handler for {event_type.value}")
|
||||
|
||||
def unregister_handler(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: EventHandler,
|
||||
) -> None:
|
||||
"""Unregister an event handler.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
handler: Handler function to remove
|
||||
"""
|
||||
if event_type in self._event_handlers:
|
||||
try:
|
||||
self._event_handlers[event_type].remove(handler)
|
||||
logger.debug(f"Unregistered handler for {event_type.value}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _dispatch_event(self, event_type: EventType, payload: dict[str, Any]) -> None:
|
||||
"""Dispatch an event to registered handlers.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
payload: Event payload
|
||||
"""
|
||||
handlers = self._event_handlers.get(event_type, [])
|
||||
for handler in handlers:
|
||||
try:
|
||||
handler(event_type, payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event handler for {event_type.value}: {e}")
|
||||
|
||||
|
||||
class MeshCoreDevice(BaseMeshCoreDevice):
|
||||
"""Real MeshCore device implementation using meshcore_py library.
|
||||
|
||||
Note: This is a placeholder implementation. The actual implementation
|
||||
would use the meshcore_py library for serial communication.
|
||||
"""
|
||||
|
||||
def __init__(self, config: DeviceConfig):
|
||||
"""Initialize real device.
|
||||
|
||||
Args:
|
||||
config: Device configuration
|
||||
"""
|
||||
super().__init__(config)
|
||||
self._running = False
|
||||
self._device = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the MeshCore device."""
|
||||
try:
|
||||
# Note: In actual implementation, this would use meshcore_py
|
||||
# from meshcore_py import MeshCore
|
||||
# self._device = MeshCore(self.config.port, self.config.baud)
|
||||
# self._device.connect()
|
||||
# self._public_key = self._device.get_public_key()
|
||||
|
||||
logger.info(f"Connecting to MeshCore device on {self.config.port}")
|
||||
|
||||
# Placeholder: In real implementation, connect via meshcore_py
|
||||
# For now, we simulate connection failure since we don't have
|
||||
# the actual device/library available
|
||||
logger.warning(
|
||||
"Real MeshCore device not available. "
|
||||
"Use --mock flag for testing."
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to device: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
if self._device:
|
||||
try:
|
||||
# self._device.disconnect()
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting: {e}")
|
||||
self._connected = False
|
||||
self._device = None
|
||||
logger.info("Disconnected from MeshCore device")
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
destination: str,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a direct message."""
|
||||
if not self._connected or not self._device:
|
||||
logger.error("Cannot send message: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
ts = timestamp or int(time.time())
|
||||
# self._device.send_message(destination, text, ts)
|
||||
logger.info(f"Sent message to {destination[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message: {e}")
|
||||
return False
|
||||
|
||||
def send_channel_message(
|
||||
self,
|
||||
channel_idx: int,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a channel message."""
|
||||
if not self._connected or not self._device:
|
||||
logger.error("Cannot send channel message: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
ts = timestamp or int(time.time())
|
||||
# self._device.send_channel_message(channel_idx, text, ts)
|
||||
logger.info(f"Sent message to channel {channel_idx}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel message: {e}")
|
||||
return False
|
||||
|
||||
def send_advertisement(self, flood: bool = True) -> bool:
|
||||
"""Send a node advertisement."""
|
||||
if not self._connected or not self._device:
|
||||
logger.error("Cannot send advertisement: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
# self._device.send_advertisement(flood)
|
||||
logger.info(f"Sent advertisement (flood={flood})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send advertisement: {e}")
|
||||
return False
|
||||
|
||||
def request_status(self, target: Optional[str] = None) -> bool:
|
||||
"""Request status from a node."""
|
||||
if not self._connected or not self._device:
|
||||
logger.error("Cannot request status: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
# self._device.request_status(target)
|
||||
logger.info(f"Requested status from {target or 'self'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to request status: {e}")
|
||||
return False
|
||||
|
||||
def request_telemetry(self, target: str) -> bool:
|
||||
"""Request telemetry from a node."""
|
||||
if not self._connected or not self._device:
|
||||
logger.error("Cannot request telemetry: not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
# self._device.request_telemetry(target)
|
||||
logger.info(f"Requested telemetry from {target[:12]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to request telemetry: {e}")
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the device event loop."""
|
||||
self._running = True
|
||||
logger.info("Starting device event loop")
|
||||
|
||||
while self._running and self._connected:
|
||||
try:
|
||||
# In actual implementation:
|
||||
# event = self._device.poll_event()
|
||||
# if event:
|
||||
# event_type = EventType(event.type)
|
||||
# self._dispatch_event(event_type, event.payload)
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in event loop: {e}")
|
||||
|
||||
logger.info("Device event loop stopped")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the device event loop."""
|
||||
self._running = False
|
||||
logger.info("Stopping device event loop")
|
||||
|
||||
|
||||
def create_device(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
) -> BaseMeshCoreDevice:
|
||||
"""Create a MeshCore device instance.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device for testing
|
||||
|
||||
Returns:
|
||||
Device instance
|
||||
"""
|
||||
config = DeviceConfig(port=port, baud=baud)
|
||||
|
||||
if mock:
|
||||
from meshcore_hub.interface.mock_device import MockMeshCoreDevice
|
||||
return MockMeshCoreDevice(config)
|
||||
|
||||
return MeshCoreDevice(config)
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Mock MeshCore device for testing without hardware."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from meshcore_hub.interface.device import (
|
||||
BaseMeshCoreDevice,
|
||||
DeviceConfig,
|
||||
EventType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockNodeConfig:
|
||||
"""Configuration for a simulated node."""
|
||||
|
||||
public_key: str
|
||||
name: str
|
||||
adv_type: str = "chat"
|
||||
flags: int = 218
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockDeviceConfig:
|
||||
"""Configuration for mock device behavior."""
|
||||
|
||||
# Device identity
|
||||
public_key: Optional[str] = None
|
||||
name: str = "MockNode"
|
||||
|
||||
# Simulated network nodes
|
||||
nodes: list[MockNodeConfig] = field(default_factory=list)
|
||||
|
||||
# Event generation intervals (seconds)
|
||||
advertisement_interval: float = 30.0
|
||||
message_interval: float = 10.0
|
||||
telemetry_interval: float = 60.0
|
||||
|
||||
# Simulation parameters
|
||||
enable_auto_events: bool = True
|
||||
message_delay_min: float = 0.1
|
||||
message_delay_max: float = 1.0
|
||||
error_rate: float = 0.0 # Probability of simulated errors
|
||||
|
||||
|
||||
def generate_random_public_key() -> str:
|
||||
"""Generate a random 64-character hex public key."""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
class MockMeshCoreDevice(BaseMeshCoreDevice):
|
||||
"""Mock MeshCore device for testing.
|
||||
|
||||
Simulates a MeshCore device for unit and integration testing
|
||||
without requiring physical hardware.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DeviceConfig,
|
||||
mock_config: Optional[MockDeviceConfig] = None,
|
||||
):
|
||||
"""Initialize mock device.
|
||||
|
||||
Args:
|
||||
config: Device configuration (port/baud are ignored)
|
||||
mock_config: Mock-specific configuration
|
||||
"""
|
||||
super().__init__(config)
|
||||
self.mock_config = mock_config or MockDeviceConfig()
|
||||
|
||||
# Generate public key if not provided
|
||||
if self.mock_config.public_key:
|
||||
self._public_key = self.mock_config.public_key
|
||||
else:
|
||||
self._public_key = generate_random_public_key()
|
||||
|
||||
# Initialize default simulated nodes if none provided
|
||||
if not self.mock_config.nodes:
|
||||
self.mock_config.nodes = self._create_default_nodes()
|
||||
|
||||
self._running = False
|
||||
self._event_thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger.info(f"Initialized mock device with public key: {self._public_key}")
|
||||
|
||||
def _create_default_nodes(self) -> list[MockNodeConfig]:
|
||||
"""Create default simulated network nodes."""
|
||||
return [
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="Alice",
|
||||
adv_type="chat",
|
||||
),
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="Bob",
|
||||
adv_type="chat",
|
||||
),
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="Repeater-01",
|
||||
adv_type="repeater",
|
||||
flags=128,
|
||||
),
|
||||
MockNodeConfig(
|
||||
public_key=generate_random_public_key(),
|
||||
name="ChatRoom",
|
||||
adv_type="room",
|
||||
),
|
||||
]
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the mock device."""
|
||||
logger.info("Connecting to mock MeshCore device")
|
||||
self._connected = True
|
||||
|
||||
# Simulate initial AppStart event
|
||||
self._dispatch_event(
|
||||
EventType.STATUS_RESPONSE,
|
||||
{
|
||||
"node_public_key": self._public_key,
|
||||
"status": "connected",
|
||||
"uptime": 0,
|
||||
"message_count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Mock device connected: {self._public_key}")
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the mock device."""
|
||||
self._connected = False
|
||||
self.stop()
|
||||
logger.info("Mock device disconnected")
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
destination: str,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a simulated direct message."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot send message: not connected")
|
||||
return False
|
||||
|
||||
if self._should_fail():
|
||||
logger.warning("Simulated send failure")
|
||||
return False
|
||||
|
||||
ts = timestamp or int(time.time())
|
||||
logger.info(f"Mock: Sending message to {destination[:12]}...: {text[:20]}...")
|
||||
|
||||
# Simulate send confirmation after delay
|
||||
delay = random.uniform(
|
||||
self.mock_config.message_delay_min,
|
||||
self.mock_config.message_delay_max,
|
||||
)
|
||||
|
||||
def send_confirmation() -> None:
|
||||
time.sleep(delay)
|
||||
self._dispatch_event(
|
||||
EventType.SEND_CONFIRMED,
|
||||
{
|
||||
"destination_public_key": destination
|
||||
if len(destination) == 64
|
||||
else destination + "0" * (64 - len(destination)),
|
||||
"round_trip_ms": int(delay * 1000),
|
||||
},
|
||||
)
|
||||
|
||||
threading.Thread(target=send_confirmation, daemon=True).start()
|
||||
return True
|
||||
|
||||
def send_channel_message(
|
||||
self,
|
||||
channel_idx: int,
|
||||
text: str,
|
||||
timestamp: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Send a simulated channel message."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot send channel message: not connected")
|
||||
return False
|
||||
|
||||
if self._should_fail():
|
||||
logger.warning("Simulated send failure")
|
||||
return False
|
||||
|
||||
ts = timestamp or int(time.time())
|
||||
logger.info(f"Mock: Sending message to channel {channel_idx}: {text[:20]}...")
|
||||
|
||||
return True
|
||||
|
||||
def send_advertisement(self, flood: bool = True) -> bool:
|
||||
"""Send a simulated advertisement."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot send advertisement: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Sending advertisement (flood={flood})")
|
||||
return True
|
||||
|
||||
def request_status(self, target: Optional[str] = None) -> bool:
|
||||
"""Request status from mock device."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot request status: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Requesting status from {target or 'self'}")
|
||||
|
||||
# Generate status response
|
||||
def send_status() -> None:
|
||||
time.sleep(0.2)
|
||||
self._dispatch_event(
|
||||
EventType.STATUS_RESPONSE,
|
||||
{
|
||||
"node_public_key": target or self._public_key,
|
||||
"status": "operational",
|
||||
"uptime": random.randint(0, 86400),
|
||||
"message_count": random.randint(0, 10000),
|
||||
},
|
||||
)
|
||||
|
||||
threading.Thread(target=send_status, daemon=True).start()
|
||||
return True
|
||||
|
||||
def request_telemetry(self, target: str) -> bool:
|
||||
"""Request telemetry from mock device."""
|
||||
if not self._connected:
|
||||
logger.error("Cannot request telemetry: not connected")
|
||||
return False
|
||||
|
||||
logger.info(f"Mock: Requesting telemetry from {target[:12]}...")
|
||||
|
||||
# Generate telemetry response
|
||||
def send_telemetry() -> None:
|
||||
time.sleep(0.3)
|
||||
self._dispatch_event(
|
||||
EventType.TELEMETRY_RESPONSE,
|
||||
{
|
||||
"node_public_key": target,
|
||||
"parsed_data": {
|
||||
"temperature": round(random.uniform(15.0, 35.0), 1),
|
||||
"humidity": random.randint(30, 90),
|
||||
"battery": round(random.uniform(3.2, 4.2), 2),
|
||||
"pressure": round(random.uniform(980.0, 1040.0), 2),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
threading.Thread(target=send_telemetry, daemon=True).start()
|
||||
return True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the mock device event loop."""
|
||||
self._running = True
|
||||
logger.info("Starting mock device event loop")
|
||||
|
||||
# Start auto event generation thread if enabled
|
||||
if self.mock_config.enable_auto_events:
|
||||
self._event_thread = threading.Thread(
|
||||
target=self._auto_event_generator,
|
||||
daemon=True,
|
||||
)
|
||||
self._event_thread.start()
|
||||
|
||||
while self._running and self._connected:
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.info("Mock device event loop stopped")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the mock device event loop."""
|
||||
self._running = False
|
||||
if self._event_thread and self._event_thread.is_alive():
|
||||
self._event_thread.join(timeout=1.0)
|
||||
logger.info("Mock device stopped")
|
||||
|
||||
def _should_fail(self) -> bool:
|
||||
"""Check if operation should fail based on error rate."""
|
||||
return random.random() < self.mock_config.error_rate
|
||||
|
||||
def _auto_event_generator(self) -> None:
|
||||
"""Generate automatic events for simulation."""
|
||||
last_adv = time.time()
|
||||
last_msg = time.time()
|
||||
last_telemetry = time.time()
|
||||
|
||||
while self._running:
|
||||
now = time.time()
|
||||
|
||||
# Generate advertisements
|
||||
if now - last_adv >= self.mock_config.advertisement_interval:
|
||||
self._generate_advertisement()
|
||||
last_adv = now
|
||||
|
||||
# Generate messages
|
||||
if now - last_msg >= self.mock_config.message_interval:
|
||||
self._generate_message()
|
||||
last_msg = now
|
||||
|
||||
# Generate telemetry
|
||||
if now - last_telemetry >= self.mock_config.telemetry_interval:
|
||||
self._generate_telemetry()
|
||||
last_telemetry = now
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
def _generate_advertisement(self) -> None:
|
||||
"""Generate a random advertisement event."""
|
||||
node = random.choice(self.mock_config.nodes)
|
||||
self._dispatch_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{
|
||||
"public_key": node.public_key,
|
||||
"name": node.name,
|
||||
"adv_type": node.adv_type,
|
||||
"flags": node.flags,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Generated advertisement from {node.name}")
|
||||
|
||||
def _generate_message(self) -> None:
|
||||
"""Generate a random message event."""
|
||||
node = random.choice(self.mock_config.nodes)
|
||||
|
||||
# Decide between contact and channel message
|
||||
if random.random() < 0.5:
|
||||
# Contact message
|
||||
sample_messages = [
|
||||
"Hello!",
|
||||
"How's the signal?",
|
||||
"Testing 1, 2, 3",
|
||||
"Great weather today!",
|
||||
"Anyone copy?",
|
||||
"Loud and clear!",
|
||||
]
|
||||
self._dispatch_event(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{
|
||||
"pubkey_prefix": node.public_key[:12],
|
||||
"text": random.choice(sample_messages),
|
||||
"path_len": random.randint(1, 10),
|
||||
"txt_type": 0,
|
||||
"SNR": round(random.uniform(-5.0, 25.0), 1),
|
||||
"sender_timestamp": int(time.time()),
|
||||
},
|
||||
)
|
||||
logger.debug(f"Generated contact message from {node.name}")
|
||||
else:
|
||||
# Channel message
|
||||
channel_messages = [
|
||||
"Hello everyone!",
|
||||
"Network check",
|
||||
"CQ CQ CQ",
|
||||
"Mesh is working great!",
|
||||
"Any repeaters online?",
|
||||
]
|
||||
self._dispatch_event(
|
||||
EventType.CHANNEL_MSG_RECV,
|
||||
{
|
||||
"channel_idx": random.choice([0, 1, 4, 7]),
|
||||
"text": random.choice(channel_messages),
|
||||
"path_len": random.randint(1, 15),
|
||||
"txt_type": 0,
|
||||
"SNR": round(random.uniform(-5.0, 25.0), 1),
|
||||
"sender_timestamp": int(time.time()),
|
||||
},
|
||||
)
|
||||
logger.debug("Generated channel message")
|
||||
|
||||
def _generate_telemetry(self) -> None:
|
||||
"""Generate a random telemetry event."""
|
||||
node = random.choice(self.mock_config.nodes)
|
||||
self._dispatch_event(
|
||||
EventType.TELEMETRY_RESPONSE,
|
||||
{
|
||||
"node_public_key": node.public_key,
|
||||
"parsed_data": {
|
||||
"temperature": round(random.uniform(15.0, 35.0), 1),
|
||||
"humidity": random.randint(30, 90),
|
||||
"battery": round(random.uniform(3.2, 4.2), 2),
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.debug(f"Generated telemetry from {node.name}")
|
||||
|
||||
def inject_event(self, event_type: EventType, payload: dict) -> None:
|
||||
"""Inject a custom event for testing.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
payload: Event payload
|
||||
"""
|
||||
self._dispatch_event(event_type, payload)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""RECEIVER mode implementation for MeshCore Interface.
|
||||
|
||||
In RECEIVER mode, the interface:
|
||||
1. Connects to a MeshCore device
|
||||
2. Subscribes to all device events
|
||||
3. Publishes events to MQTT broker
|
||||
"""
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
from meshcore_hub.interface.device import (
|
||||
BaseMeshCoreDevice,
|
||||
DeviceConfig,
|
||||
EventType,
|
||||
create_device,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Receiver:
|
||||
"""RECEIVER mode implementation.
|
||||
|
||||
Bridges MeshCore device events to MQTT broker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
):
|
||||
"""Initialize receiver.
|
||||
|
||||
Args:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
|
||||
def _handle_event(self, event_type: EventType, payload: dict[str, Any]) -> None:
|
||||
"""Handle device event and publish to MQTT.
|
||||
|
||||
Args:
|
||||
event_type: Event type
|
||||
payload: Event payload
|
||||
"""
|
||||
if not self.device.public_key:
|
||||
logger.warning("Cannot publish event: device public key not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Convert event type to MQTT topic name
|
||||
event_name = event_type.value
|
||||
|
||||
# Publish to MQTT
|
||||
self.mqtt.publish_event(
|
||||
self.device.public_key,
|
||||
event_name,
|
||||
payload,
|
||||
)
|
||||
|
||||
logger.debug(f"Published {event_name} event to MQTT")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish event to MQTT: {e}")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the receiver."""
|
||||
logger.info("Starting RECEIVER mode")
|
||||
|
||||
# Register event handlers for all event types
|
||||
for event_type in EventType:
|
||||
self.device.register_handler(event_type, self._handle_event)
|
||||
logger.debug(f"Registered handler for {event_type.value}")
|
||||
|
||||
# Connect to MQTT broker
|
||||
try:
|
||||
self.mqtt.connect()
|
||||
self.mqtt.start_background()
|
||||
logger.info("Connected to MQTT broker")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
raise
|
||||
|
||||
# Connect to device
|
||||
if not self.device.connect():
|
||||
logger.error("Failed to connect to MeshCore device")
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
raise RuntimeError("Failed to connect to MeshCore device")
|
||||
|
||||
logger.info(f"Connected to MeshCore device: {self.device.public_key}")
|
||||
|
||||
self._running = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the receiver event loop (blocking)."""
|
||||
if not self._running:
|
||||
self.start()
|
||||
|
||||
logger.info("Receiver running. Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
# Run device event loop
|
||||
self.device.run()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the receiver."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping receiver")
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Stop device
|
||||
self.device.stop()
|
||||
self.device.disconnect()
|
||||
|
||||
# Stop MQTT
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
|
||||
logger.info("Receiver stopped")
|
||||
|
||||
|
||||
def create_receiver(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> Receiver:
|
||||
"""Create a configured receiver instance.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
|
||||
Returns:
|
||||
Configured Receiver instance
|
||||
"""
|
||||
# Create device
|
||||
device = create_device(port=port, baud=baud, mock=mock)
|
||||
|
||||
# Create MQTT client
|
||||
mqtt_config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=mqtt_username,
|
||||
password=mqtt_password,
|
||||
prefix=mqtt_prefix,
|
||||
client_id=f"meshcore-receiver-{device.public_key[:8] if device.public_key else 'unknown'}",
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Receiver(device, mqtt_client)
|
||||
|
||||
|
||||
def run_receiver(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> None:
|
||||
"""Run the receiver (blocking).
|
||||
|
||||
This is the main entry point for running the receiver component.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
"""
|
||||
receiver = create_receiver(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
logger.info(f"Received signal {signum}")
|
||||
receiver.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run
|
||||
receiver.run()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""SENDER mode implementation for MeshCore Interface.
|
||||
|
||||
In SENDER mode, the interface:
|
||||
1. Connects to a MeshCore device
|
||||
2. Subscribes to command topics on MQTT broker
|
||||
3. Executes received commands on the device
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
|
||||
from meshcore_hub.interface.device import (
|
||||
BaseMeshCoreDevice,
|
||||
DeviceConfig,
|
||||
create_device,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Sender:
|
||||
"""SENDER mode implementation.
|
||||
|
||||
Bridges MQTT commands to MeshCore device.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: BaseMeshCoreDevice,
|
||||
mqtt_client: MQTTClient,
|
||||
):
|
||||
"""Initialize sender.
|
||||
|
||||
Args:
|
||||
device: MeshCore device instance
|
||||
mqtt_client: MQTT client instance
|
||||
"""
|
||||
self.device = device
|
||||
self.mqtt = mqtt_client
|
||||
self._running = False
|
||||
self._shutdown_event = threading.Event()
|
||||
|
||||
def _handle_mqtt_message(
|
||||
self,
|
||||
topic: str,
|
||||
pattern: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle incoming MQTT command message.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
pattern: Subscription pattern
|
||||
payload: Message payload
|
||||
"""
|
||||
# Parse command from topic
|
||||
parsed = self.mqtt.topic_builder.parse_command_topic(topic)
|
||||
if not parsed:
|
||||
logger.warning(f"Could not parse command topic: {topic}")
|
||||
return
|
||||
|
||||
target_key, command_name = parsed
|
||||
logger.info(f"Received command: {command_name} for {target_key[:12]}...")
|
||||
|
||||
# Dispatch command
|
||||
try:
|
||||
if command_name == "send_msg":
|
||||
self._handle_send_msg(payload)
|
||||
elif command_name == "send_channel_msg":
|
||||
self._handle_send_channel_msg(payload)
|
||||
elif command_name == "send_advert":
|
||||
self._handle_send_advert(payload)
|
||||
elif command_name == "request_status":
|
||||
self._handle_request_status(payload)
|
||||
elif command_name == "request_telemetry":
|
||||
self._handle_request_telemetry(payload)
|
||||
else:
|
||||
logger.warning(f"Unknown command: {command_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling command {command_name}: {e}")
|
||||
|
||||
def _handle_send_msg(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle send_msg command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with destination, text, timestamp
|
||||
"""
|
||||
destination = payload.get("destination")
|
||||
text = payload.get("text")
|
||||
timestamp = payload.get("timestamp")
|
||||
|
||||
if not destination or not text:
|
||||
logger.error("send_msg: missing destination or text")
|
||||
return
|
||||
|
||||
success = self.device.send_message(destination, text, timestamp)
|
||||
if success:
|
||||
logger.info(f"Message sent to {destination[:12]}...")
|
||||
else:
|
||||
logger.error(f"Failed to send message to {destination[:12]}...")
|
||||
|
||||
def _handle_send_channel_msg(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle send_channel_msg command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with channel_idx, text, timestamp
|
||||
"""
|
||||
channel_idx = payload.get("channel_idx")
|
||||
text = payload.get("text")
|
||||
timestamp = payload.get("timestamp")
|
||||
|
||||
if channel_idx is None or not text:
|
||||
logger.error("send_channel_msg: missing channel_idx or text")
|
||||
return
|
||||
|
||||
success = self.device.send_channel_message(channel_idx, text, timestamp)
|
||||
if success:
|
||||
logger.info(f"Channel message sent to channel {channel_idx}")
|
||||
else:
|
||||
logger.error(f"Failed to send message to channel {channel_idx}")
|
||||
|
||||
def _handle_send_advert(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle send_advert command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with flood flag
|
||||
"""
|
||||
flood = payload.get("flood", True)
|
||||
|
||||
success = self.device.send_advertisement(flood)
|
||||
if success:
|
||||
logger.info(f"Advertisement sent (flood={flood})")
|
||||
else:
|
||||
logger.error("Failed to send advertisement")
|
||||
|
||||
def _handle_request_status(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle request_status command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with optional target
|
||||
"""
|
||||
target = payload.get("target_public_key")
|
||||
|
||||
success = self.device.request_status(target)
|
||||
if success:
|
||||
logger.info(f"Status requested from {target or 'self'}")
|
||||
else:
|
||||
logger.error("Failed to request status")
|
||||
|
||||
def _handle_request_telemetry(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle request_telemetry command.
|
||||
|
||||
Args:
|
||||
payload: Command payload with target
|
||||
"""
|
||||
target = payload.get("target_public_key")
|
||||
|
||||
if not target:
|
||||
logger.error("request_telemetry: missing target_public_key")
|
||||
return
|
||||
|
||||
success = self.device.request_telemetry(target)
|
||||
if success:
|
||||
logger.info(f"Telemetry requested from {target[:12]}...")
|
||||
else:
|
||||
logger.error("Failed to request telemetry")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the sender."""
|
||||
logger.info("Starting SENDER mode")
|
||||
|
||||
# Connect to device first
|
||||
if not self.device.connect():
|
||||
logger.error("Failed to connect to MeshCore device")
|
||||
raise RuntimeError("Failed to connect to MeshCore device")
|
||||
|
||||
logger.info(f"Connected to MeshCore device: {self.device.public_key}")
|
||||
|
||||
# Connect to MQTT broker
|
||||
try:
|
||||
self.mqtt.connect()
|
||||
self.mqtt.start_background()
|
||||
logger.info("Connected to MQTT broker")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
self.device.disconnect()
|
||||
raise
|
||||
|
||||
# Subscribe to command topics
|
||||
# Using wildcard to receive commands for any node
|
||||
command_topic = self.mqtt.topic_builder.all_commands_topic()
|
||||
self.mqtt.subscribe(command_topic, self._handle_mqtt_message)
|
||||
logger.info(f"Subscribed to command topic: {command_topic}")
|
||||
|
||||
self._running = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the sender event loop (blocking)."""
|
||||
if not self._running:
|
||||
self.start()
|
||||
|
||||
logger.info("Sender running. Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
while self._running and not self._shutdown_event.is_set():
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the sender."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping sender")
|
||||
self._running = False
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Stop MQTT
|
||||
self.mqtt.stop()
|
||||
self.mqtt.disconnect()
|
||||
|
||||
# Stop device
|
||||
self.device.stop()
|
||||
self.device.disconnect()
|
||||
|
||||
logger.info("Sender stopped")
|
||||
|
||||
|
||||
def create_sender(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> Sender:
|
||||
"""Create a configured sender instance.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
|
||||
Returns:
|
||||
Configured Sender instance
|
||||
"""
|
||||
# Create device
|
||||
device = create_device(port=port, baud=baud, mock=mock)
|
||||
|
||||
# Create MQTT client
|
||||
mqtt_config = MQTTConfig(
|
||||
host=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=mqtt_username,
|
||||
password=mqtt_password,
|
||||
prefix=mqtt_prefix,
|
||||
client_id=f"meshcore-sender-{device.public_key[:8] if device.public_key else 'unknown'}",
|
||||
)
|
||||
mqtt_client = MQTTClient(mqtt_config)
|
||||
|
||||
return Sender(device, mqtt_client)
|
||||
|
||||
|
||||
def run_sender(
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baud: int = 115200,
|
||||
mock: bool = False,
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mqtt_username: Optional[str] = None,
|
||||
mqtt_password: Optional[str] = None,
|
||||
mqtt_prefix: str = "meshcore",
|
||||
) -> None:
|
||||
"""Run the sender (blocking).
|
||||
|
||||
This is the main entry point for running the sender component.
|
||||
|
||||
Args:
|
||||
port: Serial port path
|
||||
baud: Baud rate
|
||||
mock: Use mock device
|
||||
mqtt_host: MQTT broker host
|
||||
mqtt_port: MQTT broker port
|
||||
mqtt_username: MQTT username
|
||||
mqtt_password: MQTT password
|
||||
mqtt_prefix: MQTT topic prefix
|
||||
"""
|
||||
sender = create_sender(
|
||||
port=port,
|
||||
baud=baud,
|
||||
mock=mock,
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
mqtt_prefix=mqtt_prefix,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
logger.info(f"Received signal {signum}")
|
||||
sender.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run
|
||||
sender.run()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Fixtures for interface component tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.interface.device import DeviceConfig, EventType
|
||||
from meshcore_hub.interface.mock_device import MockDeviceConfig, MockMeshCoreDevice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_config() -> DeviceConfig:
|
||||
"""Create a device configuration for testing."""
|
||||
return DeviceConfig(
|
||||
port="/dev/ttyUSB0",
|
||||
baud=115200,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_config() -> MockDeviceConfig:
|
||||
"""Create a mock device configuration for testing."""
|
||||
return MockDeviceConfig(
|
||||
public_key="a" * 64,
|
||||
name="TestNode",
|
||||
enable_auto_events=False, # Disable auto events for testing
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device(device_config, mock_device_config) -> MockMeshCoreDevice:
|
||||
"""Create a mock device instance for testing."""
|
||||
device = MockMeshCoreDevice(device_config, mock_device_config)
|
||||
yield device
|
||||
if device.is_connected:
|
||||
device.disconnect()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for device abstraction."""
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.interface.device import (
|
||||
DeviceConfig,
|
||||
EventType,
|
||||
MeshCoreDevice,
|
||||
create_device,
|
||||
)
|
||||
|
||||
|
||||
class TestDeviceConfig:
|
||||
"""Tests for DeviceConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default configuration values."""
|
||||
config = DeviceConfig()
|
||||
|
||||
assert config.port == "/dev/ttyUSB0"
|
||||
assert config.baud == 115200
|
||||
assert config.timeout == 1.0
|
||||
assert config.reconnect_delay == 5.0
|
||||
assert config.max_reconnect_attempts == 10
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""Test custom configuration values."""
|
||||
config = DeviceConfig(
|
||||
port="/dev/ttyACM0",
|
||||
baud=9600,
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
assert config.port == "/dev/ttyACM0"
|
||||
assert config.baud == 9600
|
||||
assert config.timeout == 2.0
|
||||
|
||||
|
||||
class TestEventType:
|
||||
"""Tests for EventType enumeration."""
|
||||
|
||||
def test_event_types(self) -> None:
|
||||
"""Test event type values."""
|
||||
assert EventType.ADVERTISEMENT.value == "advertisement"
|
||||
assert EventType.CONTACT_MSG_RECV.value == "contact_msg_recv"
|
||||
assert EventType.CHANNEL_MSG_RECV.value == "channel_msg_recv"
|
||||
assert EventType.TRACE_DATA.value == "trace_data"
|
||||
assert EventType.TELEMETRY_RESPONSE.value == "telemetry_response"
|
||||
|
||||
|
||||
class TestCreateDevice:
|
||||
"""Tests for create_device factory function."""
|
||||
|
||||
def test_create_mock_device(self) -> None:
|
||||
"""Test creating a mock device."""
|
||||
device = create_device(mock=True)
|
||||
|
||||
assert device is not None
|
||||
assert device.public_key is not None
|
||||
assert len(device.public_key) == 64
|
||||
|
||||
def test_create_real_device(self) -> None:
|
||||
"""Test creating a real device."""
|
||||
device = create_device(mock=False)
|
||||
|
||||
assert device is not None
|
||||
assert isinstance(device, MeshCoreDevice)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for mock device implementation."""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import threading
|
||||
|
||||
from meshcore_hub.interface.device import EventType
|
||||
from meshcore_hub.interface.mock_device import (
|
||||
MockDeviceConfig,
|
||||
MockMeshCoreDevice,
|
||||
MockNodeConfig,
|
||||
generate_random_public_key,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateRandomPublicKey:
|
||||
"""Tests for public key generation."""
|
||||
|
||||
def test_generates_64_char_hex(self) -> None:
|
||||
"""Test that public key is 64 hex characters."""
|
||||
key = generate_random_public_key()
|
||||
|
||||
assert len(key) == 64
|
||||
assert all(c in "0123456789abcdef" for c in key)
|
||||
|
||||
def test_generates_unique_keys(self) -> None:
|
||||
"""Test that generated keys are unique."""
|
||||
keys = [generate_random_public_key() for _ in range(100)]
|
||||
|
||||
assert len(set(keys)) == 100
|
||||
|
||||
|
||||
class TestMockDeviceConfig:
|
||||
"""Tests for MockDeviceConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default configuration values."""
|
||||
config = MockDeviceConfig()
|
||||
|
||||
assert config.public_key is None
|
||||
assert config.name == "MockNode"
|
||||
assert config.enable_auto_events is True
|
||||
assert config.advertisement_interval == 30.0
|
||||
assert config.message_interval == 10.0
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""Test custom configuration values."""
|
||||
config = MockDeviceConfig(
|
||||
public_key="a" * 64,
|
||||
name="CustomNode",
|
||||
enable_auto_events=False,
|
||||
)
|
||||
|
||||
assert config.public_key == "a" * 64
|
||||
assert config.name == "CustomNode"
|
||||
assert config.enable_auto_events is False
|
||||
|
||||
|
||||
class TestMockMeshCoreDevice:
|
||||
"""Tests for MockMeshCoreDevice."""
|
||||
|
||||
def test_connection(self, mock_device) -> None:
|
||||
"""Test device connection."""
|
||||
assert not mock_device.is_connected
|
||||
|
||||
result = mock_device.connect()
|
||||
|
||||
assert result is True
|
||||
assert mock_device.is_connected
|
||||
|
||||
def test_public_key(self, mock_device) -> None:
|
||||
"""Test public key assignment."""
|
||||
assert mock_device.public_key == "a" * 64
|
||||
|
||||
def test_disconnect(self, mock_device) -> None:
|
||||
"""Test device disconnection."""
|
||||
mock_device.connect()
|
||||
assert mock_device.is_connected
|
||||
|
||||
mock_device.disconnect()
|
||||
|
||||
assert not mock_device.is_connected
|
||||
|
||||
def test_send_message(self, mock_device) -> None:
|
||||
"""Test sending a message."""
|
||||
mock_device.connect()
|
||||
|
||||
result = mock_device.send_message(
|
||||
destination="b" * 64,
|
||||
text="Hello!",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_send_message_not_connected(self, mock_device) -> None:
|
||||
"""Test sending message when not connected."""
|
||||
result = mock_device.send_message(
|
||||
destination="b" * 64,
|
||||
text="Hello!",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_send_channel_message(self, mock_device) -> None:
|
||||
"""Test sending a channel message."""
|
||||
mock_device.connect()
|
||||
|
||||
result = mock_device.send_channel_message(
|
||||
channel_idx=4,
|
||||
text="Channel message",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_send_advertisement(self, mock_device) -> None:
|
||||
"""Test sending an advertisement."""
|
||||
mock_device.connect()
|
||||
|
||||
result = mock_device.send_advertisement(flood=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_request_status(self, mock_device) -> None:
|
||||
"""Test requesting status."""
|
||||
mock_device.connect()
|
||||
|
||||
result = mock_device.request_status()
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_request_telemetry(self, mock_device) -> None:
|
||||
"""Test requesting telemetry."""
|
||||
mock_device.connect()
|
||||
|
||||
result = mock_device.request_telemetry(target="c" * 64)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_event_handler_registration(self, mock_device) -> None:
|
||||
"""Test event handler registration."""
|
||||
events_received = []
|
||||
|
||||
def handler(event_type, payload):
|
||||
events_received.append((event_type, payload))
|
||||
|
||||
mock_device.register_handler(EventType.ADVERTISEMENT, handler)
|
||||
mock_device.connect()
|
||||
|
||||
# Inject an event
|
||||
mock_device.inject_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{"public_key": "d" * 64, "name": "TestNode"},
|
||||
)
|
||||
|
||||
# Give time for event processing
|
||||
time.sleep(0.1)
|
||||
|
||||
assert len(events_received) >= 1
|
||||
event_type, payload = events_received[-1]
|
||||
assert event_type == EventType.ADVERTISEMENT
|
||||
assert payload["name"] == "TestNode"
|
||||
|
||||
def test_event_handler_unregistration(self, mock_device) -> None:
|
||||
"""Test event handler unregistration."""
|
||||
events_received = []
|
||||
|
||||
def handler(event_type, payload):
|
||||
events_received.append((event_type, payload))
|
||||
|
||||
mock_device.register_handler(EventType.ADVERTISEMENT, handler)
|
||||
mock_device.unregister_handler(EventType.ADVERTISEMENT, handler)
|
||||
|
||||
mock_device.connect()
|
||||
mock_device.inject_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{"public_key": "d" * 64, "name": "TestNode"},
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
# Should only have the status event from connect(), not the advertisement
|
||||
advert_events = [e for e in events_received if e[0] == EventType.ADVERTISEMENT]
|
||||
assert len(advert_events) == 0
|
||||
|
||||
def test_default_nodes_created(self, device_config) -> None:
|
||||
"""Test that default nodes are created when none provided."""
|
||||
device = MockMeshCoreDevice(device_config)
|
||||
|
||||
assert len(device.mock_config.nodes) > 0
|
||||
assert any(n.adv_type == "chat" for n in device.mock_config.nodes)
|
||||
assert any(n.adv_type == "repeater" for n in device.mock_config.nodes)
|
||||
|
||||
def test_custom_nodes(self, device_config) -> None:
|
||||
"""Test custom node configuration."""
|
||||
custom_nodes = [
|
||||
MockNodeConfig(
|
||||
public_key="e" * 64,
|
||||
name="CustomAlice",
|
||||
adv_type="chat",
|
||||
),
|
||||
]
|
||||
config = MockDeviceConfig(nodes=custom_nodes)
|
||||
device = MockMeshCoreDevice(device_config, config)
|
||||
|
||||
assert len(device.mock_config.nodes) == 1
|
||||
assert device.mock_config.nodes[0].name == "CustomAlice"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for receiver mode implementation."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from meshcore_hub.interface.device import DeviceConfig, EventType
|
||||
from meshcore_hub.interface.mock_device import MockDeviceConfig, MockMeshCoreDevice
|
||||
from meshcore_hub.interface.receiver import Receiver, create_receiver
|
||||
|
||||
|
||||
class TestReceiver:
|
||||
"""Tests for Receiver class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mqtt_client(self):
|
||||
"""Create a mock MQTT client."""
|
||||
client = MagicMock()
|
||||
client.topic_builder = MagicMock()
|
||||
client.topic_builder.event_topic.return_value = "meshcore/abc/event/test"
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def receiver(self, mock_device, mock_mqtt_client):
|
||||
"""Create a receiver instance."""
|
||||
return Receiver(mock_device, mock_mqtt_client)
|
||||
|
||||
def test_start_connects_device_and_mqtt(self, receiver, mock_device, mock_mqtt_client):
|
||||
"""Test that start connects to device and MQTT."""
|
||||
receiver.start()
|
||||
|
||||
assert mock_device.is_connected
|
||||
mock_mqtt_client.connect.assert_called_once()
|
||||
mock_mqtt_client.start_background.assert_called_once()
|
||||
|
||||
def test_stop_disconnects_device_and_mqtt(self, receiver, mock_device, mock_mqtt_client):
|
||||
"""Test that stop disconnects device and MQTT."""
|
||||
receiver.start()
|
||||
receiver.stop()
|
||||
|
||||
assert not mock_device.is_connected
|
||||
mock_mqtt_client.stop.assert_called_once()
|
||||
mock_mqtt_client.disconnect.assert_called_once()
|
||||
|
||||
def test_events_published_to_mqtt(self, receiver, mock_device, mock_mqtt_client):
|
||||
"""Test that device events are published to MQTT."""
|
||||
receiver.start()
|
||||
|
||||
# Inject an event
|
||||
mock_device.inject_event(
|
||||
EventType.ADVERTISEMENT,
|
||||
{"public_key": "b" * 64, "name": "TestNode"},
|
||||
)
|
||||
|
||||
# Allow time for event processing
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
# Verify MQTT publish was called
|
||||
mock_mqtt_client.publish_event.assert_called()
|
||||
|
||||
|
||||
class TestCreateReceiver:
|
||||
"""Tests for create_receiver factory function."""
|
||||
|
||||
def test_creates_receiver_with_mock_device(self):
|
||||
"""Test creating receiver with mock device."""
|
||||
with patch("meshcore_hub.interface.receiver.MQTTClient") as MockMQTT:
|
||||
receiver = create_receiver(mock=True)
|
||||
|
||||
assert receiver is not None
|
||||
assert receiver.device is not None
|
||||
assert receiver.device.public_key is not None
|
||||
|
||||
def test_creates_receiver_with_custom_mqtt_config(self):
|
||||
"""Test creating receiver with custom MQTT configuration."""
|
||||
with patch("meshcore_hub.interface.receiver.MQTTClient") as MockMQTT:
|
||||
receiver = create_receiver(
|
||||
mock=True,
|
||||
mqtt_host="mqtt.example.com",
|
||||
mqtt_port=8883,
|
||||
mqtt_prefix="custom",
|
||||
)
|
||||
|
||||
# Verify MQTT client was created with correct config
|
||||
MockMQTT.assert_called_once()
|
||||
config = MockMQTT.call_args[0][0]
|
||||
assert config.host == "mqtt.example.com"
|
||||
assert config.port == 8883
|
||||
assert config.prefix == "custom"
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for sender mode implementation."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from meshcore_hub.interface.device import DeviceConfig, EventType
|
||||
from meshcore_hub.interface.mock_device import MockDeviceConfig, MockMeshCoreDevice
|
||||
from meshcore_hub.interface.sender import Sender, create_sender
|
||||
|
||||
|
||||
class TestSender:
|
||||
"""Tests for Sender class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mqtt_client(self):
|
||||
"""Create a mock MQTT client."""
|
||||
client = MagicMock()
|
||||
client.topic_builder = MagicMock()
|
||||
client.topic_builder.parse_command_topic.return_value = ("abc123", "send_msg")
|
||||
client.topic_builder.all_commands_topic.return_value = "meshcore/+/command/#"
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def sender(self, mock_device, mock_mqtt_client):
|
||||
"""Create a sender instance."""
|
||||
return Sender(mock_device, mock_mqtt_client)
|
||||
|
||||
def test_start_connects_device_and_mqtt(self, sender, mock_device, mock_mqtt_client):
|
||||
"""Test that start connects to device and MQTT."""
|
||||
sender.start()
|
||||
|
||||
assert mock_device.is_connected
|
||||
mock_mqtt_client.connect.assert_called_once()
|
||||
mock_mqtt_client.start_background.assert_called_once()
|
||||
mock_mqtt_client.subscribe.assert_called_once()
|
||||
|
||||
def test_stop_disconnects_device_and_mqtt(self, sender, mock_device, mock_mqtt_client):
|
||||
"""Test that stop disconnects device and MQTT."""
|
||||
sender.start()
|
||||
sender.stop()
|
||||
|
||||
assert not mock_device.is_connected
|
||||
mock_mqtt_client.stop.assert_called_once()
|
||||
mock_mqtt_client.disconnect.assert_called_once()
|
||||
|
||||
def test_handle_send_msg_command(self, sender, mock_device, mock_mqtt_client):
|
||||
"""Test handling send_msg command."""
|
||||
sender.start()
|
||||
|
||||
# Simulate receiving a send_msg command
|
||||
sender._handle_mqtt_message(
|
||||
topic="meshcore/abc/command/send_msg",
|
||||
pattern="meshcore/+/command/#",
|
||||
payload={
|
||||
"destination": "b" * 64,
|
||||
"text": "Hello!",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify message was sent (device is mocked, so just check no error)
|
||||
assert mock_device.is_connected
|
||||
|
||||
def test_handle_send_channel_msg_command(self, sender, mock_device, mock_mqtt_client):
|
||||
"""Test handling send_channel_msg command."""
|
||||
mock_mqtt_client.topic_builder.parse_command_topic.return_value = (
|
||||
"abc123",
|
||||
"send_channel_msg",
|
||||
)
|
||||
sender.start()
|
||||
|
||||
sender._handle_mqtt_message(
|
||||
topic="meshcore/abc/command/send_channel_msg",
|
||||
pattern="meshcore/+/command/#",
|
||||
payload={
|
||||
"channel_idx": 4,
|
||||
"text": "Channel broadcast",
|
||||
},
|
||||
)
|
||||
|
||||
assert mock_device.is_connected
|
||||
|
||||
def test_handle_send_advert_command(self, sender, mock_device, mock_mqtt_client):
|
||||
"""Test handling send_advert command."""
|
||||
mock_mqtt_client.topic_builder.parse_command_topic.return_value = (
|
||||
"abc123",
|
||||
"send_advert",
|
||||
)
|
||||
sender.start()
|
||||
|
||||
sender._handle_mqtt_message(
|
||||
topic="meshcore/abc/command/send_advert",
|
||||
pattern="meshcore/+/command/#",
|
||||
payload={"flood": True},
|
||||
)
|
||||
|
||||
assert mock_device.is_connected
|
||||
|
||||
|
||||
class TestCreateSender:
|
||||
"""Tests for create_sender factory function."""
|
||||
|
||||
def test_creates_sender_with_mock_device(self):
|
||||
"""Test creating sender with mock device."""
|
||||
with patch("meshcore_hub.interface.sender.MQTTClient") as MockMQTT:
|
||||
sender = create_sender(mock=True)
|
||||
|
||||
assert sender is not None
|
||||
assert sender.device is not None
|
||||
assert sender.device.public_key is not None
|
||||
|
||||
def test_creates_sender_with_custom_mqtt_config(self):
|
||||
"""Test creating sender with custom MQTT configuration."""
|
||||
with patch("meshcore_hub.interface.sender.MQTTClient") as MockMQTT:
|
||||
sender = create_sender(
|
||||
mock=True,
|
||||
mqtt_host="mqtt.example.com",
|
||||
mqtt_port=8883,
|
||||
mqtt_prefix="custom",
|
||||
)
|
||||
|
||||
MockMQTT.assert_called_once()
|
||||
config = MockMQTT.call_args[0][0]
|
||||
assert config.host == "mqtt.example.com"
|
||||
assert config.port == 8883
|
||||
assert config.prefix == "custom"
|
||||
Reference in New Issue
Block a user