refactor: Improve channel handling and logging in command and message processing

- Enhanced the CommandManager to support both pymc and standard meshcore modes for sending channel messages, improving flexibility in message handling.
- Updated the MessageHandler to prioritize channel name retrieval from payloads in pymc mode, ensuring accurate sender identification.
- Refined the ChannelDatabaseAdapter to streamline channel loading from configuration, including private channels, and improved logging for better operational transparency.
- Introduced a method in MessageScheduler to check for pymc connection mode, optimizing channel operation processing based on connection type.
This commit is contained in:
agessaman committed 2026-02-02 16:10:55 -08:00
1 parent c08dba316b
commit 4754e8122b
4 files changed
+437 -236

No files matched your search

+31 -22
View File
@@ -291,13 +291,13 @@ class CommandManager:
def _handle_send_result(self, result, operation_name: str, target: str, used_retry_method: bool = False) -> bool:
"""Handle result from message send operations.
Args:
result: Result object from meshcore send operation.
operation_name: Name of the operation ("DM" or "Channel message").
target: Recipient name or channel name for logging.
used_retry_method: True if send_msg_with_retry was used (affects logging).
Returns:
bool: True if send succeeded (ACK received or sent successfully), False otherwise.
"""
@@ -307,14 +307,17 @@ class CommandManager:
else:
self.logger.error(f"❌ {operation_name} to {target} failed - no result returned")
return False
if hasattr(result, 'type'):
if result.type == EventType.ERROR:
# Get event type name for comparison (handles different EventType enums)
event_type_name = getattr(result.type, 'name', str(result.type)).upper()
if event_type_name == 'ERROR':
error_payload = result.payload if hasattr(result, 'payload') else {}
self.logger.error(f"❌ {operation_name} failed to {target}: {error_payload if error_payload else 'Unknown error'}")
return False
if result.type in (EventType.MSG_SENT, EventType.OK):
if event_type_name in ('MSG_SENT', 'OK', 'MESSAGE_SENT'):
if used_retry_method and operation_name == "DM":
self.logger.info(f"✅ {operation_name} sent and ACK received from {target}")
else:
@@ -732,16 +735,8 @@ class CommandManager:
return False
try:
# Get channel number from channel name
channel_num = self.bot.channel_manager.get_channel_number(channel)
# Check if channel was found (None indicates channel name not found)
if channel_num is None:
self.logger.error(f"Channel '{channel}' not found. Cannot send message.")
return False
self.logger.info(f"Sending channel message to {channel} (channel {channel_num}): {content}")
self.logger.info(f"Sending channel message to {channel}: {content}")
# Record transmission for repeat tracking (don't let this block sending)
try:
if hasattr(self.bot, 'transmission_tracker') and self.bot.transmission_tracker:
@@ -756,13 +751,27 @@ class CommandManager:
except Exception as e:
self.logger.debug(f"Error recording transmission for repeat tracking: {e}")
# Don't fail the send if transmission tracking fails
# Use meshcore-cli send_chan_msg function
from meshcore_cli.meshcore_cli import send_chan_msg
result = await send_chan_msg(self.bot.meshcore, channel_num, content)
# Check if using pymc mode (has send_channel_msg on commands)
if hasattr(self.bot.meshcore, 'commands') and hasattr(self.bot.meshcore.commands, 'send_channel_msg'):
# pymc mode - use channel name directly
result = await self.bot.meshcore.commands.send_channel_msg(channel, content)
target = channel
else:
# Standard meshcore mode - need channel number
channel_num = self.bot.channel_manager.get_channel_number(channel)
# Check if channel was found (None indicates channel name not found)
if channel_num is None:
self.logger.error(f"Channel '{channel}' not found. Cannot send message.")
return False
# Use meshcore-cli send_chan_msg function
from meshcore_cli.meshcore_cli import send_chan_msg
result = await send_chan_msg(self.bot.meshcore, channel_num, content)
target = f"{channel} (channel {channel_num})"
# Handle result using unified handler
target = f"{channel} (channel {channel_num})"
return self._handle_send_result(result, "Channel message", target)
except Exception as e:
+146 -130
View File
@@ -1472,10 +1472,16 @@ class MessageHandler:
# Always strip trailing whitespace/newlines from message content to handle cases like "Wx 98104\n"
message_content = message_content.strip()
# Get channel name from channel number
channel_name = self.bot.channel_manager.get_channel_name(channel_idx)
# Get channel name - prefer the one from payload (pymc mode), fall back to channel_manager lookup
channel_name = payload.get('channel_name') or payload.get('channel', '')
if not channel_name and hasattr(self.bot, 'channel_manager') and self.bot.channel_manager:
channel_name = self.bot.channel_manager.get_channel_name(channel_idx)
# Get sender name from payload if available (pymc mode provides this directly)
if payload.get('sender_name') and payload.get('sender_name') != 'Unknown':
sender_id = payload.get('sender_name')
self.logger.info(f"Received channel message ({channel_name}) from {sender_id}: {text}")
# Get SNR and RSSI using the same logic as contact messages
@@ -1529,147 +1535,157 @@ class MessageHandler:
message_packet_prefix = message_raw_hex[:32] if message_raw_hex else None
message_pubkey = payload.get('pubkey_prefix', '') # Keep for contact lookup
self.logger.debug(f"Processing channel message from packet prefix: {message_packet_prefix}, pubkey: {message_pubkey}")
# Enhanced RF data correlation with multiple strategies
recent_rf_data = None
# Strategy 1: Try immediate correlation using packet prefix
if message_packet_prefix:
recent_rf_data = self.find_recent_rf_data(message_packet_prefix)
elif message_pubkey:
# Fallback to pubkey correlation
recent_rf_data = self.find_recent_rf_data(message_pubkey)
# Strategy 2: If no immediate match and enhanced correlation is enabled, store message and wait briefly
if not recent_rf_data and self.enhanced_correlation:
import time
correlation_key = message_packet_prefix or message_pubkey
message_id = f"{correlation_key}_{int(time.time() * 1000)}"
self.store_message_for_correlation(message_id, payload)
# Wait a short time for RF data to arrive (non-blocking)
await asyncio.sleep(0.1) # 100ms wait
recent_rf_data = self.correlate_message_with_rf_data(message_id)
# Strategy 3: Try with extended timeout if still no match
if not recent_rf_data:
extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout
# Check if we already have path info from the payload (pymc mode)
# In pymc mode, path info comes directly from the packet, no RF correlation needed
if payload.get('path') is not None:
# pymc mode - use data directly from payload
path_string = payload.get('path')
hops = payload.get('path_len', 0)
self.logger.debug(f"Using path from payload (pymc mode): {path_string} ({hops} hops)")
recent_rf_data = None # Skip RF correlation
else:
# meshcore mode - need RF data correlation
# Enhanced RF data correlation with multiple strategies
recent_rf_data = None
# Strategy 1: Try immediate correlation using packet prefix
if message_packet_prefix:
recent_rf_data = self.find_recent_rf_data(message_packet_prefix, max_age_seconds=extended_timeout)
recent_rf_data = self.find_recent_rf_data(message_packet_prefix)
elif message_pubkey:
recent_rf_data = self.find_recent_rf_data(message_pubkey, max_age_seconds=extended_timeout)
# Fallback to pubkey correlation
recent_rf_data = self.find_recent_rf_data(message_pubkey)
# Strategy 4: Use most recent RF data as last resort
if not recent_rf_data:
extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout
recent_rf_data = self.find_recent_rf_data(max_age_seconds=extended_timeout)
# Strategy 2: If no immediate match and enhanced correlation is enabled, store message and wait briefly
if not recent_rf_data and self.enhanced_correlation:
import time
correlation_key = message_packet_prefix or message_pubkey
message_id = f"{correlation_key}_{int(time.time() * 1000)}"
self.store_message_for_correlation(message_id, payload)
# Wait a short time for RF data to arrive (non-blocking)
await asyncio.sleep(0.1) # 100ms wait
recent_rf_data = self.correlate_message_with_rf_data(message_id)
# Strategy 3: Try with extended timeout if still no match
if not recent_rf_data:
extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout
if message_packet_prefix:
recent_rf_data = self.find_recent_rf_data(message_packet_prefix, max_age_seconds=extended_timeout)
elif message_pubkey:
recent_rf_data = self.find_recent_rf_data(message_pubkey, max_age_seconds=extended_timeout)
# Strategy 4: Use most recent RF data as last resort
if not recent_rf_data:
extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout
recent_rf_data = self.find_recent_rf_data(max_age_seconds=extended_timeout)
if recent_rf_data and recent_rf_data.get('raw_hex'):
raw_hex = recent_rf_data['raw_hex']
self.logger.info(f"🔍 FOUND RF DATA: {len(raw_hex)} chars, starts with: {raw_hex[:32]}...")
self.logger.debug(f"Full RF data: {raw_hex}")
# Extract SNR/RSSI from the RF data
if recent_rf_data.get('snr'):
snr = recent_rf_data['snr']
self.logger.debug(f"Using SNR from RF data: {snr}")
if recent_rf_data.get('rssi'):
rssi = recent_rf_data['rssi']
self.logger.debug(f"Using RSSI from RF data: {rssi}")
# Try to extract path information from raw hex directly
path_string = None
hops = payload.get('path_len', 255)
# First try the packet decoder
# Use payload field if available, otherwise use raw_hex
payload_hex = recent_rf_data.get('payload')
packet_info = self.decode_meshcore_packet(raw_hex, payload_hex)
# Get packet_hash from recent_rf_data if available (for trace correlation)
packet_hash = recent_rf_data.get('packet_hash')
if packet_hash and packet_info:
packet_info['packet_hash'] = packet_hash
if packet_info and packet_info.get('path_len') is not None:
# Valid packet decoded - use the results even if path is empty (0 hops = direct)
hops = packet_info.get('path_len', 0)
# Check if this is a TRACE packet with SNR data
if packet_info.get('payload_type') == 9: # TRACE packet
# For TRACE packets, extract routing path from payload pathHashes
# The path field contains SNR data, but the actual routing path is in payload
path_info = packet_info.get('path_info', {})
path_hashes = path_info.get('path_hashes') or path_info.get('path', [])
if path_hashes:
# Convert pathHashes to path string
path_string = ','.join(path_hashes)
self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string} ({len(path_hashes)} hops)")
# Update mesh graph with trace path - bot is the destination, so we can confirm these edges
# Since the bot received this trace packet, it's the destination node
self._update_mesh_graph_from_trace(path_hashes, packet_info)
if recent_rf_data and recent_rf_data.get('raw_hex'):
raw_hex = recent_rf_data['raw_hex']
self.logger.info(f"🔍 FOUND RF DATA: {len(raw_hex)} chars, starts with: {raw_hex[:32]}...")
self.logger.debug(f"Full RF data: {raw_hex}")
# Extract SNR/RSSI from the RF data
if recent_rf_data.get('snr'):
snr = recent_rf_data['snr']
self.logger.debug(f"Using SNR from RF data: {snr}")
if recent_rf_data.get('rssi'):
rssi = recent_rf_data['rssi']
self.logger.debug(f"Using RSSI from RF data: {rssi}")
# Try to extract path information from raw hex directly
path_string = None
hops = payload.get('path_len', 255)
# First try the packet decoder
# Use payload field if available, otherwise use raw_hex
payload_hex = recent_rf_data.get('payload')
packet_info = self.decode_meshcore_packet(raw_hex, payload_hex)
# Get packet_hash from recent_rf_data if available (for trace correlation)
packet_hash = recent_rf_data.get('packet_hash')
if packet_hash and packet_info:
packet_info['packet_hash'] = packet_hash
if packet_info and packet_info.get('path_len') is not None:
# Valid packet decoded - use the results even if path is empty (0 hops = direct)
hops = packet_info.get('path_len', 0)
# Check if this is a TRACE packet with SNR data
if packet_info.get('payload_type') == 9: # TRACE packet
# For TRACE packets, extract routing path from payload pathHashes
# The path field contains SNR data, but the actual routing path is in payload
path_info = packet_info.get('path_info', {})
path_hashes = path_info.get('path_hashes') or path_info.get('path', [])
if path_hashes:
# Convert pathHashes to path string
path_string = ','.join(path_hashes)
self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string} ({len(path_hashes)} hops)")
# Update mesh graph with trace path - bot is the destination, so we can confirm these edges
# Since the bot received this trace packet, it's the destination node
self._update_mesh_graph_from_trace(path_hashes, packet_info)
else:
path_string = "Direct" if hops == 0 else f"Unknown routing ({hops} hops)"
self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string}")
else:
path_string = "Direct" if hops == 0 else f"Unknown routing ({hops} hops)"
self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string}")
else:
# For all other packet types, try multiple methods to get the path
path_string = None
# Method 1: Try path_nodes field first
path_nodes = packet_info.get('path_nodes', [])
if path_nodes:
path_string = ','.join(path_nodes)
self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)")
# Update mesh graph with path edges
self._update_mesh_graph(path_nodes, packet_info)
else:
# Method 2: Try path_hex field
path_hex = packet_info.get('path_hex', '')
if path_hex and len(path_hex) >= 2:
# Convert hex string to node list (every 2 characters = 1 node)
path_nodes = [path_hex[i:i+2] for i in range(0, len(path_hex), 2)]
# For all other packet types, try multiple methods to get the path
path_string = None
# Method 1: Try path_nodes field first
path_nodes = packet_info.get('path_nodes', [])
if path_nodes:
path_string = ','.join(path_nodes)
self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET HEX: {path_string} ({hops} hops)")
self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)")
# Update mesh graph with path edges
self._update_mesh_graph(path_nodes, packet_info)
else:
# Method 3: Try path_info.path field
path_info = packet_info.get('path_info', {})
if path_info and path_info.get('path'):
path_nodes = path_info['path']
# Method 2: Try path_hex field
path_hex = packet_info.get('path_hex', '')
if path_hex and len(path_hex) >= 2:
# Convert hex string to node list (every 2 characters = 1 node)
path_nodes = [path_hex[i:i+2] for i in range(0, len(path_hex), 2)]
path_string = ','.join(path_nodes)
self.logger.info(f"🎯 EXTRACTED PATH FROM PATH_INFO: {path_string} ({hops} hops)")
self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET HEX: {path_string} ({hops} hops)")
# Update mesh graph with path edges
self._update_mesh_graph(path_nodes, packet_info)
else:
# No path found - this is truly unknown
path_string = "Direct" if hops == 0 else "Unknown routing"
self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)")
else:
# Packet decoding failed - try to extract path directly from raw hex
self.logger.debug("Packet decoding failed, trying direct hex parsing")
path_string = self.extract_path_from_raw_hex(raw_hex, hops)
if path_string:
self.logger.info(f"🎯 EXTRACTED PATH FROM RAW HEX: {path_string} ({hops} hops)")
# Method 3: Try path_info.path field
path_info = packet_info.get('path_info', {})
if path_info and path_info.get('path'):
path_nodes = path_info['path']
path_string = ','.join(path_nodes)
self.logger.info(f"🎯 EXTRACTED PATH FROM PATH_INFO: {path_string} ({hops} hops)")
# Update mesh graph with path edges
self._update_mesh_graph(path_nodes, packet_info)
else:
# No path found - this is truly unknown
path_string = "Direct" if hops == 0 else "Unknown routing"
self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)")
else:
# Try to use routing info from RF data as fallback
if recent_rf_data.get('routing_info') and recent_rf_data['routing_info'].get('path_nodes'):
routing_info = recent_rf_data['routing_info']
hops = len(routing_info['path_nodes'])
path_string = ','.join(routing_info['path_nodes'])
self.logger.info(f"🎯 EXTRACTED PATH FROM RF ROUTING INFO: {path_string} ({hops} hops)")
# Packet decoding failed - try to extract path directly from raw hex
self.logger.debug("Packet decoding failed, trying direct hex parsing")
path_string = self.extract_path_from_raw_hex(raw_hex, hops)
if path_string:
self.logger.info(f"🎯 EXTRACTED PATH FROM RAW HEX: {path_string} ({hops} hops)")
else:
# Final fallback to basic path info
self.logger.debug("No path info available, using basic path info")
path_string = None
else:
self.logger.warning("❌ NO RF DATA found for channel message after all correlation attempts")
hops = payload.get('path_len', 255)
path_string = None
# Try to use routing info from RF data as fallback
if recent_rf_data.get('routing_info') and recent_rf_data['routing_info'].get('path_nodes'):
routing_info = recent_rf_data['routing_info']
hops = len(routing_info['path_nodes'])
path_string = ','.join(routing_info['path_nodes'])
self.logger.info(f"🎯 EXTRACTED PATH FROM RF ROUTING INFO: {path_string} ({hops} hops)")
else:
# Final fallback to basic path info
self.logger.debug("No path info available, using basic path info")
path_string = None
else:
self.logger.warning("❌ NO RF DATA found for channel message after all correlation attempts")
hops = payload.get('path_len', 255)
path_string = None
# Get the full public key from contacts if available
sender_pubkey = payload.get('pubkey_prefix', '')
if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts:
+130 -46
View File
@@ -161,9 +161,6 @@ class ChannelDatabaseAdapter:
# Well-known Public channel secret (from MeshCore spec)
PUBLIC_CHANNEL_SECRET = "d8ee687c9be53be08d24a7f7aede4dac5de3168dea03c12e7b9c96c5511e807f"
# Common hashtag channels to always include
COMMON_HASHTAG_CHANNELS = ['#Public', '#general', '#test']
def __init__(self, bot):
self.bot = bot
self.logger = logging.getLogger("ChannelDatabaseAdapter")
@@ -171,24 +168,33 @@ class ChannelDatabaseAdapter:
self._cache_time = 0
self._cache_ttl = 30 # Refresh cache every 30 seconds
# Pre-load channels and log what we have
self.logger.info("ChannelDatabaseAdapter initialized - loading channels...")
channels = self.get_channels()
self.logger.info(f"Initialized with {len(channels)} channels:")
for ch in channels:
ch_hash = self.get_channel_hash(ch['secret'])
self.logger.info(f" - {ch['name']}: hash=0x{ch_hash:02X}")
@staticmethod
def derive_hashtag_key(channel_name: str) -> str:
"""
Derive channel key from a hashtag channel name.
For channels starting with #, the key is derived by SHA256 hashing
the channel name (including the #).
MeshCore uses the first 16 bytes of SHA256(channel_name) as the
encryption key for hashtag channels.
Args:
channel_name: Channel name (e.g., "#howltest")
Returns:
Hex string of the 32-byte derived key
Hex string of the 16-byte derived key (32 hex chars)
"""
import hashlib
# Hash the channel name (including #) to get the key
key = hashlib.sha256(channel_name.encode('utf-8')).digest()
return key.hex()
# Hash the channel name, take first 16 bytes as the key
full_hash = hashlib.sha256(channel_name.encode('utf-8')).digest()
key_16 = full_hash[:16]
return key_16.hex()
@staticmethod
def get_channel_hash(secret_hex: str) -> int:
@@ -211,8 +217,13 @@ class ChannelDatabaseAdapter:
Includes:
- Public channel (well-known key)
- Hashtag channels from config (derived keys)
- Private channels from config (name:key pairs)
- Custom channels from database
Config format in [Channels] section:
monitor_channels = #howltest, #mychannel
private_channels = mynet:abc123..., othernet:def456...
Returns:
List of channel dicts with 'name' and 'secret' keys
"""
@@ -236,27 +247,16 @@ class ChannelDatabaseAdapter:
})
self.logger.info(f"Added Public channel with hash 0x{public_hash:02X}")
# Add common hashtag channels
for ch_name in self.COMMON_HASHTAG_CHANNELS:
derived_key = self.derive_hashtag_key(ch_name)
ch_hash = self.get_channel_hash(derived_key)
if not any(c['name'] == ch_name for c in channels):
channels.append({
'name': ch_name,
'secret': derived_key,
'idx': len(channels)
})
self.logger.info(f"Added common channel {ch_name} with hash 0x{ch_hash:02X}")
# Add hashtag channels from config
# Add channels from config
try:
if hasattr(self.bot, 'config'):
# Hashtag channels (key derived from name)
monitor_channels = self.bot.config.get('Channels', 'monitor_channels', fallback='')
if monitor_channels:
for ch_name in monitor_channels.split(','):
ch_name = ch_name.strip()
if ch_name.startswith('#') and not any(c['name'] == ch_name for c in channels):
# Derive key for hashtag channel
if ch_name and not any(c['name'] == ch_name for c in channels):
# Derive key from channel name
derived_key = self.derive_hashtag_key(ch_name)
ch_hash = self.get_channel_hash(derived_key)
channels.append({
@@ -264,11 +264,29 @@ class ChannelDatabaseAdapter:
'secret': derived_key,
'idx': len(channels)
})
self.logger.debug(f"Added config channel {ch_name} with hash 0x{ch_hash:02X}")
except Exception as e:
self.logger.warning(f"Error loading hashtag channels from config: {e}")
self.logger.info(f"Added channel {ch_name} with hash 0x{ch_hash:02X}")
# Add channels from database
# Private channels (explicit name:key pairs)
private_channels = self.bot.config.get('Channels', 'private_channels', fallback='')
if private_channels:
for pair in private_channels.split(','):
pair = pair.strip()
if ':' in pair:
ch_name, ch_key = pair.split(':', 1)
ch_name = ch_name.strip()
ch_key = ch_key.strip()
if ch_name and ch_key and not any(c['name'] == ch_name for c in channels):
ch_hash = self.get_channel_hash(ch_key)
channels.append({
'name': ch_name,
'secret': ch_key,
'idx': len(channels)
})
self.logger.info(f"Added private channel {ch_name} with hash 0x{ch_hash:02X}")
except Exception as e:
self.logger.warning(f"Error loading channels from config: {e}")
# Add channels from database (added via web viewer)
try:
if hasattr(self.bot, 'db_manager'):
query = """
@@ -284,13 +302,15 @@ class ChannelDatabaseAdapter:
channel_key_hex = row.get('channel_key_hex', '')
if channel_name and channel_key_hex:
# Don't duplicate if already added
# Don't duplicate if already added from config
if not any(c['name'] == channel_name for c in channels):
ch_hash = self.get_channel_hash(channel_key_hex)
channels.append({
'name': channel_name,
'secret': channel_key_hex,
'idx': row.get('channel_idx', 0)
})
self.logger.info(f"Added database channel {channel_name} with hash 0x{ch_hash:02X}")
except Exception as e:
self.logger.warning(f"Error loading channels from database: {e}")
@@ -737,30 +757,38 @@ class PyMCConnection:
async def _handle_advert_packet(self, pkt, snr: float, rssi: float) -> None:
"""Handle advertisement packet from dispatcher."""
try:
# Extract advertisement info from packet
# The advert handler in pymc_core parses this
# Use pymc_core's advert parsing utilities
from pymc_core.protocol.utils import parse_advert_payload, decode_appdata
payload = pkt.payload if hasattr(pkt, 'payload') else b''
# Basic info we can extract
# Default event payload
event_payload = {
'public_key': payload[2:34].hex() if len(payload) >= 34 else '',
'name': '', # Name is in the advert data after pubkey
'public_key': '',
'name': '',
'latitude': None,
'longitude': None,
'flags': payload[1] if len(payload) > 1 else 0,
'flags': 0,
'SNR': snr,
'RSSI': rssi,
'raw_hex': payload.hex() if payload else ''
}
# Try to extract name from advert
if len(payload) > 34:
try:
name_end = payload.find(b'\x00', 34)
if name_end > 34:
event_payload['name'] = payload[34:name_end].decode('utf-8', errors='ignore')
except Exception:
pass
# Parse the advert packet properly
try:
parsed = parse_advert_payload(payload)
event_payload['public_key'] = parsed.get('pubkey', '')
# Decode the appdata to get name, location, flags
appdata = parsed.get('appdata', b'')
if appdata:
decoded = decode_appdata(appdata)
event_payload['name'] = decoded.get('node_name', '') or decoded.get('name', '')
event_payload['latitude'] = decoded.get('latitude')
event_payload['longitude'] = decoded.get('longitude')
event_payload['flags'] = decoded.get('flags', 0)
except Exception as e:
self.logger.warning(f"Error parsing advert payload: {e}")
self.logger.info(f"Advertisement from {event_payload.get('name', 'unknown')}")
@@ -806,14 +834,34 @@ class PyMCConnection:
# The GroupTextHandler in pymc_core stores decrypted data in pkt.decrypted
group_data = getattr(pkt, 'decrypted', {}).get('group_text_data', {})
text = group_data.get('text', '')
# Strip null bytes from text (padding from decryption)
text = group_data.get('text', '').rstrip('\x00')
channel_name = group_data.get('channel_name', '')
sender_name = group_data.get('sender_name', 'Unknown')
channel_hash = group_data.get('channel_hash', 0)
# If group_data is empty, the message wasn't decrypted (missing channel key)
if not group_data:
self.logger.warning(f"Channel message not decrypted - missing channel key? hash=0x{payload[0]:02X}")
ch_hash = payload[0] if payload else 0
self.logger.warning(f"Channel message not decrypted - unknown channel hash=0x{ch_hash:02X}")
# Log what channels we DO know about for debugging
if hasattr(self, '_channel_db') and self._channel_db:
known_hashes = []
for ch in self._channel_db.get_channels():
h = self._channel_db.get_channel_hash(ch['secret'])
known_hashes.append(f"{ch['name']}=0x{h:02X}")
self.logger.debug(f"Known channels: {', '.join(known_hashes)}")
# Extract path information from the packet
path_len = getattr(pkt, 'path_len', 0)
path_bytes = getattr(pkt, 'path', b'')
path_string = None
if path_len > 0 and path_bytes:
# Convert path bytes to hex string (each byte is a node hash)
path_nodes = [f"{b:02x}" for b in path_bytes[:path_len]]
path_string = f"{','.join(path_nodes)} ({path_len} hops)"
elif path_len == 0:
path_string = "Direct"
event_payload = {
'text': text,
@@ -824,6 +872,8 @@ class PyMCConnection:
'sender_name': sender_name,
'SNR': snr,
'RSSI': rssi,
'path': path_string,
'path_len': path_len,
'raw_hex': payload.hex() if payload else ''
}
@@ -930,7 +980,41 @@ class PyMCCommands:
except Exception as e:
self.logger.error(f"Error sending advertisement: {e}")
return Event(type=EventType.ERROR, payload={'error': str(e)})
async def send_channel_msg(self, channel_name: str, content: str) -> Event:
"""
Send a message to a channel.
Args:
channel_name: Name of the channel (e.g., "#howltest")
content: Message content to send
Returns:
Event indicating success or failure
"""
try:
if not self.connection._mesh_node:
return Event(type=EventType.ERROR, payload={'error': 'Not connected'})
self.logger.info(f"Sending channel message to {channel_name}: {content[:50]}...")
# Use pymc_core's send_group_text method
result = await self.connection._mesh_node.send_group_text(channel_name, content)
if result.get('success'):
self.logger.info(f"Channel message sent to {channel_name}")
return Event(type=EventType.MSG_SENT, payload={'sent': True, 'channel': channel_name})
else:
error = result.get('error', 'Unknown error')
self.logger.error(f"Failed to send channel message: {error}")
return Event(type=EventType.ERROR, payload={'error': error})
except Exception as e:
self.logger.error(f"Error sending channel message: {e}")
import traceback
self.logger.error(traceback.format_exc())
return Event(type=EventType.ERROR, payload={'error': str(e)})
async def get_time(self) -> Event:
"""
Get device time (not applicable for pyMC, return current time).
+130 -38
View File
@@ -382,10 +382,13 @@ class MessageScheduler:
# Process pending channel operations from web viewer (every 5 seconds)
if not hasattr(self, 'last_channel_ops_check_time'):
self.last_channel_ops_check_time = 0
if time.time() - self.last_channel_ops_check_time >= 5: # Every 5 seconds
if (hasattr(self.bot, 'channel_manager') and self.bot.channel_manager and
hasattr(self.bot, 'connected') and self.bot.connected):
# Process if connected - works for both meshcore (channel_manager) and pymc modes
can_process = (hasattr(self.bot, 'connected') and self.bot.connected and
(hasattr(self.bot, 'channel_manager') and self.bot.channel_manager or
self._is_pymc_connection()))
if can_process:
import asyncio
if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running():
# Schedule coroutine in the running main event loop
@@ -508,16 +511,24 @@ class MessageScheduler:
except Exception as e:
self.logger.error(f"Error sending interval-based advert: {e}")
def _is_pymc_connection(self) -> bool:
"""Check if we're using pymc connection mode"""
try:
from .pymc_connection import PyMCConnection
return isinstance(self.bot.meshcore, PyMCConnection)
except ImportError:
return False
async def _process_channel_operations(self):
"""Process pending channel operations from the web viewer"""
try:
db_path = str(self.bot.db_manager.db_path) # Ensure string, not Path object
# Get pending operations
with sqlite3.connect(db_path, timeout=30.0) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT id, operation_type, channel_idx, channel_name, channel_key_hex
FROM channel_operations
@@ -525,52 +536,63 @@ class MessageScheduler:
ORDER BY created_at ASC
LIMIT 10
''')
operations = cursor.fetchall()
if not operations:
return
self.logger.info(f"Processing {len(operations)} pending channel operation(s)")
# Check if using pymc connection (channels stored in DB only, no radio)
is_pymc = self._is_pymc_connection()
for op in operations:
op_id = op['id']
op_type = op['operation_type']
channel_idx = op['channel_idx']
channel_name = op['channel_name']
channel_key_hex = op['channel_key_hex']
try:
success = False
error_msg = None
if op_type == 'add':
# Add channel
if channel_key_hex:
# Custom channel with key
channel_secret = bytes.fromhex(channel_key_hex)
success = await self.bot.channel_manager.add_channel(
channel_idx, channel_name, channel_secret=channel_secret
)
else:
# Hashtag channel (firmware generates key)
success = await self.bot.channel_manager.add_channel(
channel_idx, channel_name
)
if success:
self.logger.info(f"Successfully processed channel add operation: {channel_name} at index {channel_idx}")
else:
error_msg = "Failed to add channel"
elif op_type == 'remove':
# Remove channel
success = await self.bot.channel_manager.remove_channel(channel_idx)
if success:
self.logger.info(f"Successfully processed channel remove operation: index {channel_idx}")
else:
error_msg = "Failed to remove channel"
if is_pymc:
# For pymc mode, store directly in database
# The ChannelDatabaseAdapter will pick up channels from the DB
success, error_msg = await self._process_pymc_channel_operation(
op_type, channel_idx, channel_name, channel_key_hex
)
else:
# Standard meshcore mode - use channel_manager to talk to radio
if op_type == 'add':
# Add channel
if channel_key_hex:
# Custom channel with key
channel_secret = bytes.fromhex(channel_key_hex)
success = await self.bot.channel_manager.add_channel(
channel_idx, channel_name, channel_secret=channel_secret
)
else:
# Hashtag channel (firmware generates key)
success = await self.bot.channel_manager.add_channel(
channel_idx, channel_name
)
if success:
self.logger.info(f"Successfully processed channel add operation: {channel_name} at index {channel_idx}")
else:
error_msg = "Failed to add channel"
elif op_type == 'remove':
# Remove channel
success = await self.bot.channel_manager.remove_channel(channel_idx)
if success:
self.logger.info(f"Successfully processed channel remove operation: index {channel_idx}")
else:
error_msg = "Failed to remove channel"
# Update operation status
with sqlite3.connect(db_path, timeout=30.0) as conn:
@@ -623,3 +645,73 @@ class MessageScheduler:
self.logger.error(f"Parent directory: {parent} (exists: {parent.exists()}, writable: {os.access(str(parent), os.W_OK) if parent.exists() else False})")
else:
self.logger.error(f"Database path: {db_path_str}")
async def _process_pymc_channel_operation(self, op_type: str, channel_idx: int,
channel_name: str, channel_key_hex: str) -> tuple:
"""
Process channel operation for pymc mode by storing directly in database.
For pymc mode, channels are stored in the database and the ChannelDatabaseAdapter
reads from there. There's no radio firmware to configure.
Args:
op_type: 'add' or 'remove'
channel_idx: Channel index
channel_name: Channel name
channel_key_hex: Channel key as hex string (for private channels)
Returns:
Tuple of (success: bool, error_msg: str or None)
"""
import hashlib
try:
db_path = str(self.bot.db_manager.db_path)
if op_type == 'add':
# Derive key if not provided (hashtag channel)
if not channel_key_hex and channel_name:
# Derive key from channel name using SHA256
key_bytes = hashlib.sha256(channel_name.encode('utf-8')).digest()
channel_key_hex = key_bytes.hex()
# Determine channel type
channel_type = 'hashtag' if channel_name.startswith('#') else 'custom'
# Insert/update channel in database
with sqlite3.connect(db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO channels
(channel_idx, channel_name, channel_type, channel_key_hex, last_updated)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (channel_idx, channel_name, channel_type, channel_key_hex))
conn.commit()
# Invalidate channel cache in the adapter
if hasattr(self.bot.meshcore, '_channel_db'):
self.bot.meshcore._channel_db._cache_time = 0
self.logger.info(f"[pymc] Added channel {channel_idx}: {channel_name} (type={channel_type})")
return True, None
elif op_type == 'remove':
# Remove channel from database
with sqlite3.connect(db_path, timeout=30.0) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM channels WHERE channel_idx = ?', (channel_idx,))
conn.commit()
# Invalidate channel cache in the adapter
if hasattr(self.bot.meshcore, '_channel_db'):
self.bot.meshcore._channel_db._cache_time = 0
self.logger.info(f"[pymc] Removed channel {channel_idx}")
return True, None
else:
return False, f"Unknown operation type: {op_type}"
except Exception as e:
self.logger.error(f"Error in pymc channel operation: {e}")
return False, str(e)