Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()`
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 36 additions & 4 deletions src/meshbot/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/meshbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/meshbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 21 additions & 4 deletions src/meshbot/meshcore_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]:
Expand Down
Loading