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:
Claude
2025-12-02 23:16:58 +00:00
parent 3c1625d4c9
commit 15023b8d4a
11 changed files with 2169 additions and 74 deletions
+3 -74
View File
@@ -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()
+297
View File
@@ -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,
)
+393
View File
@@ -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)
+406
View File
@@ -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)
+224
View File
@@ -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()
+324
View File
@@ -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()
+35
View File
@@ -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()
+67
View File
@@ -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)
+206
View File
@@ -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"
+89
View File
@@ -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"
+125
View File
@@ -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"