diff --git a/.env.example b/.env.example index 582371e..2a5c89b 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,13 @@ MESHCORE_LISTEN_CHANNEL=0 # MESHCORE_BAUDRATE=115200 # MESHCORE_DEBUG=false # MESHCORE_AUTO_RECONNECT=true +# Message sending delays (for LoRa duty cycle compliance) +# Delay between multi-chunk messages in seconds (default: 5.0) +# This respects LoRa duty cycle restrictions (1% in Europe requires ~50s wait after 500ms message) +# Reduce for faster regions (US: 2-3s), increase for stricter requirements (Europe: 5-10s) +# MESHCORE_MESSAGE_DELAY=5.0 +# Number of retry attempts for failed message sends (default: 1) +# MESHCORE_MESSAGE_RETRY=1 # Weather Configuration # Required: Set coordinates for weather queries diff --git a/AGENTS.md b/AGENTS.md index 995d89e..68d1c6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,8 @@ pytest tests/test_basic.py -v - API request limits (max 5 requests per message via UsageLimits) - Network context injection (last 5 network events included in prompts) - Graceful handling of usage limit errors + - **LoRa duty cycle compliance** - configurable delay between message chunks (default: 5.0s) + - **Retry logic** - automatic retry with exponential backoff for failed message sends (default: 1 retry) 3. **Memory System** (`src/meshbot/memory.py` + `src/meshbot/storage.py`) - File-based storage using text files and CSV @@ -371,6 +373,14 @@ The agent includes network situational awareness implemented in `src/meshbot/mes - Graceful error handling for usage limit exceeded - Prevents runaway API costs from excessive tool calling +**LoRa Duty Cycle Management** (`src/meshbot/agent.py`): +- Configurable message delay between chunks (default: 5.0s) +- Environment variable: `MESHCORE_MESSAGE_DELAY` (default: 5.0 seconds) +- Respects LoRa duty cycle restrictions (1% in Europe requires ~50s wait after 500ms message) +- Automatic retry with exponential backoff (2s, 4s, 8s...) for failed sends +- Environment variable: `MESHCORE_MESSAGE_RETRY` (default: 1 retry) +- Enhanced error logging for debugging message delivery issues + ### Adding New Configuration Option 1. Add field to appropriate config class in `src/meshbot/config.py` 2. Add environment variable loading with `os.getenv()` diff --git a/README.md b/README.md index 80550bd..318982c 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,14 @@ LISTEN_CHANNEL=0 # Which channel to monitor # Message length MAX_MESSAGE_LENGTH=120 # Character limit per message chunk + +# LoRa duty cycle compliance (prevents message loss) +MESHCORE_MESSAGE_DELAY=5.0 # Delay between message chunks (seconds) +MESHCORE_MESSAGE_RETRY=1 # Number of retry attempts for failed sends ``` +**Note**: The `MESHCORE_MESSAGE_DELAY` setting is critical for reliable message delivery over LoRa. LoRa radios have duty cycle restrictions (e.g., 1% in Europe requires ~50 seconds of wait time after a 500ms transmission). The default 5.0 second delay provides a safe balance between speed and reliability. Reduce to 2-3 seconds for less restrictive regions (US), or increase to 7-10 seconds for very strict requirements. + ## Chat Logs Conversation history and network data are stored in simple text files: diff --git a/src/meshbot/agent.py b/src/meshbot/agent.py index f93eef0..0c064ff 100644 --- a/src/meshbot/agent.py +++ b/src/meshbot/agent.py @@ -55,6 +55,8 @@ def __init__( base_url: Optional[str] = None, max_message_length: int = 120, node_name: Optional[str] = None, + message_delay: float = 5.0, + message_retry_count: int = 1, **meshcore_kwargs, ): self.model = model @@ -65,6 +67,8 @@ def __init__( self.base_url = base_url self.max_message_length = max_message_length self.node_name = node_name + self.message_delay = message_delay + self.message_retry_count = message_retry_count self.meshcore_kwargs = meshcore_kwargs self._mention_name: Optional[ str @@ -517,14 +521,42 @@ async def _handle_message( ) logger.info(f"Message chunks: {message_chunks}") - # Send all chunks + # Send all chunks with retry logic for i, chunk in enumerate(message_chunks): logger.info(f"Sending chunk {i+1}/{len(message_chunks)}: {chunk}") - await self.meshcore.send_message(destination, chunk) - # Small delay between messages to avoid flooding + # Try sending with retries + success = False + for attempt in range(self.message_retry_count + 1): + if attempt > 0: + retry_delay = 2.0 ** attempt # Exponential backoff: 2s, 4s, 8s... + logger.warning( + f"Retry attempt {attempt}/{self.message_retry_count} after {retry_delay}s delay" + ) + await asyncio.sleep(retry_delay) + + success = await self.meshcore.send_message(destination, chunk) + + if success: + logger.debug(f"Chunk {i+1}/{len(message_chunks)} sent successfully") + break + else: + logger.warning( + f"Failed to send chunk {i+1}/{len(message_chunks)} (attempt {attempt+1}/{self.message_retry_count+1})" + ) + + if not success: + logger.error( + f"Failed to send chunk {i+1}/{len(message_chunks)} after {self.message_retry_count+1} attempts" + ) + # Continue trying to send remaining chunks even if one fails + + # Delay between messages to respect LoRa duty cycle if i < len(message_chunks) - 1: - await asyncio.sleep(0.5) + logger.debug( + f"Waiting {self.message_delay}s before next chunk (LoRa duty cycle)" + ) + await asyncio.sleep(self.message_delay) # Store assistant response in memory (original full response) # For channels, use channel as user_id; for DMs, use sender diff --git a/src/meshbot/config.py b/src/meshbot/config.py index bec6a8f..a9aff82 100644 --- a/src/meshbot/config.py +++ b/src/meshbot/config.py @@ -42,6 +42,12 @@ class MeshCoreConfig: timeout: int = field( default_factory=lambda: int(os.getenv("MESHCORE_TIMEOUT", "30")) ) + message_delay: float = field( + default_factory=lambda: float(os.getenv("MESHCORE_MESSAGE_DELAY", "5.0")) + ) + message_retry_count: int = field( + default_factory=lambda: int(os.getenv("MESHCORE_MESSAGE_RETRY", "1")) + ) @dataclass diff --git a/src/meshbot/main.py b/src/meshbot/main.py index 7123e10..44e0ff9 100644 --- a/src/meshbot/main.py +++ b/src/meshbot/main.py @@ -172,6 +172,8 @@ def run( max_message_length=app_config.ai.max_message_length, base_url=app_config.ai.base_url, node_name=app_config.meshcore.node_name, + message_delay=app_config.meshcore.message_delay, + message_retry_count=app_config.meshcore.message_retry_count, port=app_config.meshcore.port, baudrate=app_config.meshcore.baudrate, host=app_config.meshcore.host, @@ -369,6 +371,8 @@ async def run_test(): max_message_length=app_config.ai.max_message_length, base_url=app_config.ai.base_url, node_name=app_config.meshcore.node_name, + message_delay=app_config.meshcore.message_delay, + message_retry_count=app_config.meshcore.message_retry_count, port=app_config.meshcore.port, baudrate=app_config.meshcore.baudrate, host=app_config.meshcore.host, diff --git a/src/meshbot/meshcore_interface.py b/src/meshbot/meshcore_interface.py index 6c3e88b..7205854 100644 --- a/src/meshbot/meshcore_interface.py +++ b/src/meshbot/meshcore_interface.py @@ -449,6 +449,9 @@ async def send_message(self, destination: str, message: str) -> bool: True if message was sent successfully, False otherwise """ if not self._connected or not self._meshcore: + logger.warning( + f"Cannot send message - not connected (connected={self._connected}, meshcore={self._meshcore is not None})" + ) return False try: @@ -461,18 +464,32 @@ async def send_message(self, destination: str, message: str) -> bool: ): # Send to channel channel_id = int(destination) - logger.debug(f"Sending to channel {channel_id}") + logger.debug( + f"Sending to channel {channel_id}: {message[:50]}{'...' if len(message) > 50 else ''}" + ) result = await self._meshcore.commands.send_chan_msg( channel_id, message ) else: # Send direct message to contact (public key) - logger.debug(f"Sending direct message to {destination[:16]}...") + logger.debug( + f"Sending direct message to {destination[:16]}...: {message[:50]}{'...' if len(message) > 50 else ''}" + ) result = await self._meshcore.commands.send_msg(destination, message) - return result is not None + if result is not None: + logger.debug(f"Message sent successfully to {destination}") + return True + else: + logger.warning( + f"Message send returned None (possible queue full or radio busy) - destination: {destination}" + ) + return False + except Exception as e: - logger.error(f"Failed to send message: {e}") + logger.error( + f"Failed to send message to {destination}: {type(e).__name__}: {e}" + ) return False async def get_contacts(self) -> List[MeshCoreContact]: