diff --git a/src/meshcore_hub/__main__.py b/src/meshcore_hub/__main__.py index c744995..e29d2ca 100644 --- a/src/meshcore_hub/__main__.py +++ b/src/meshcore_hub/__main__.py @@ -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() diff --git a/src/meshcore_hub/interface/cli.py b/src/meshcore_hub/interface/cli.py new file mode 100644 index 0000000..19757da --- /dev/null +++ b/src/meshcore_hub/interface/cli.py @@ -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, + ) diff --git a/src/meshcore_hub/interface/device.py b/src/meshcore_hub/interface/device.py new file mode 100644 index 0000000..fd686ba --- /dev/null +++ b/src/meshcore_hub/interface/device.py @@ -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) diff --git a/src/meshcore_hub/interface/mock_device.py b/src/meshcore_hub/interface/mock_device.py new file mode 100644 index 0000000..f3f2eda --- /dev/null +++ b/src/meshcore_hub/interface/mock_device.py @@ -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) diff --git a/src/meshcore_hub/interface/receiver.py b/src/meshcore_hub/interface/receiver.py new file mode 100644 index 0000000..22ac6e0 --- /dev/null +++ b/src/meshcore_hub/interface/receiver.py @@ -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() diff --git a/src/meshcore_hub/interface/sender.py b/src/meshcore_hub/interface/sender.py new file mode 100644 index 0000000..6245687 --- /dev/null +++ b/src/meshcore_hub/interface/sender.py @@ -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() diff --git a/tests/test_interface/conftest.py b/tests/test_interface/conftest.py new file mode 100644 index 0000000..96b4532 --- /dev/null +++ b/tests/test_interface/conftest.py @@ -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() diff --git a/tests/test_interface/test_device.py b/tests/test_interface/test_device.py new file mode 100644 index 0000000..73e0894 --- /dev/null +++ b/tests/test_interface/test_device.py @@ -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) diff --git a/tests/test_interface/test_mock_device.py b/tests/test_interface/test_mock_device.py new file mode 100644 index 0000000..5c05361 --- /dev/null +++ b/tests/test_interface/test_mock_device.py @@ -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" diff --git a/tests/test_interface/test_receiver.py b/tests/test_interface/test_receiver.py new file mode 100644 index 0000000..1ca2fb4 --- /dev/null +++ b/tests/test_interface/test_receiver.py @@ -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" diff --git a/tests/test_interface/test_sender.py b/tests/test_interface/test_sender.py new file mode 100644 index 0000000..8ed614f --- /dev/null +++ b/tests/test_interface/test_sender.py @@ -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"