mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 22:32:29 +00:00
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:
1 parent
c08dba316b
commit
4754e8122b
4 files changed
+437
-236
No files matched your search
+31
-22
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user