feat: Enhance web viewer functionality and configuration options

- Updated `config.ini.example` to include a new option for additional hashtag channels to decode in the packet stream.
- Modified `BotDataViewer` to retrieve and display additional decode-only channels from the configuration.
- Improved packet handling in `message_handler.py` to capture full packet data for web viewer integration.
- Enhanced the web viewer's JavaScript to support detailed packet analysis and display, including color-coded hex breakdowns and improved user interface elements.
- Added new styles and scripts to the web viewer templates for better visual representation of packet data and improved user experience.
This commit is contained in:
agessaman
2026-01-10 16:39:07 -08:00
parent 760bf7ad1f
commit ca80924e38
10 changed files with 11481 additions and 125 deletions
+1
View File
@@ -332,3 +332,4 @@ This project is licensed under the MIT License.
- [MeshCore Project](https://github.com/meshcore-dev/MeshCore) for the mesh networking protocol
- Some commands adapted from MeshingAround bot by K7MHI Kelly Keeton 2024
- Packet capture service based on [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) by agessaman
- [meshcore-decoder](https://github.com/michaelhart/meshcore-decoder) by Michael Hart for client-side packet decoding and decryption in the web viewer
+14 -1
View File
@@ -262,7 +262,7 @@ ping = "Pong!"
pong = "Ping!"
help = "Bot Help: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | More: 'help <command>'"
Override 'cmd' command output
# Override 'cmd' command output
# cmd = "Available commands: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats"
[Channels]
@@ -806,6 +806,19 @@ auto_start = false
# Default: bot_data.db
db_path = bot_data.db
# Additional hashtag channels to decode in the packet stream
# The web viewer can decrypt GroupText messages from hashtag channels
# without adding them to the radio. Enter channel names (with or without #)
# as a comma-separated list. The Public channel is always included.
#
# Example: #CQ,#ARES,#EmComm
# This will allow decryption of messages on #CQ, #ARES, and #EmComm channels
# in addition to any channels configured on your radio.
#
# Note: Only hashtag channels work here - custom channels with private keys
# must be added to the radio itself.
decode_hashtag_channels =
####################################################################################################
# #
# Service Plugins Config #
+4
View File
@@ -668,6 +668,10 @@ class MessageHandler:
if (hasattr(self.bot, 'web_viewer_integration') and
self.bot.web_viewer_integration and
self.bot.web_viewer_integration.bot_integration):
# Use extracted_payload which is the full MeshCore packet
# (header + path_len + path + payload, without RF wrapper)
decoded_packet['raw_packet_hex'] = extracted_payload if extracted_payload else raw_hex
decoded_packet['packet_hash'] = packet_hash
self.bot.web_viewer_integration.bot_integration.capture_full_packet_data(decoded_packet)
# Process ADVERT packets for contact tracking (regardless of path length)
+70 -7
View File
@@ -37,7 +37,12 @@ class BotDataViewer:
# This is the directory containing the modules folder
self.bot_root = Path(os.path.join(os.path.dirname(__file__), '..', '..')).resolve()
self.app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), 'templates'))
self.app = Flask(
__name__,
template_folder=os.path.join(os.path.dirname(__file__), 'templates'),
static_folder=os.path.join(os.path.dirname(__file__), 'static'),
static_url_path='/static'
)
self.app.config['SECRET_KEY'] = 'meshcore_bot_viewer_secret'
# Flask-SocketIO configuration following 5.x best practices
@@ -2933,33 +2938,57 @@ class BotDataViewer:
return []
def _get_channels(self):
"""Get all configured channels from database"""
"""Get all configured channels from database plus additional decode-only channels"""
import sqlite3
conn = None
try:
conn = self._get_db_connection()
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT channel_idx, channel_name, channel_type, channel_key_hex, last_updated
FROM channels
ORDER BY channel_idx
''')
rows = cursor.fetchall()
channels = []
existing_names = set()
for row in rows:
name = row['channel_name']
channels.append({
'channel_idx': row['channel_idx'],
'index': row['channel_idx'], # Alias for compatibility
'name': row['channel_name'],
'channel_name': row['channel_name'], # Alias for compatibility
'name': name,
'channel_name': name, # Alias for compatibility
'type': row['channel_type'] or 'hashtag',
'key_hex': row['channel_key_hex'],
'last_updated': row['last_updated']
})
# Track names for deduplication (normalize to lowercase with #)
normalized = name.lower() if name.startswith('#') else f'#{name.lower()}'
existing_names.add(normalized)
# Add additional decode-only hashtag channels from config
additional_channels = self._get_additional_decode_channels()
for channel_name in additional_channels:
# Normalize name
normalized = channel_name.lower() if channel_name.startswith('#') else f'#{channel_name.lower()}'
if normalized not in existing_names:
channels.append({
'channel_idx': None, # Not a real radio channel
'index': None,
'name': normalized,
'channel_name': normalized,
'type': 'hashtag',
'key_hex': None, # Key will be derived client-side
'last_updated': None,
'decode_only': True # Flag to indicate this is decode-only
})
existing_names.add(normalized)
return channels
except Exception as e:
self.logger.error(f"Error getting channels: {e}")
@@ -2968,6 +2997,40 @@ class BotDataViewer:
if conn:
conn.close()
def _get_additional_decode_channels(self):
"""Get additional hashtag channels to decode from config"""
channels = set() # Use set for automatic deduplication
try:
# 1. Get channels from decode_hashtag_channels in [Web_Viewer]
if self.config and self.config.has_option('Web_Viewer', 'decode_hashtag_channels'):
channels_str = self.config.get('Web_Viewer', 'decode_hashtag_channels', fallback='')
if channels_str:
for c in channels_str.split(','):
c = c.strip().lower()
if c:
# Remove # prefix if present for normalization
if c.startswith('#'):
c = c[1:]
channels.add(c)
# 2. Import channels from [Channels_List] section
if self.config and self.config.has_section('Channels_List'):
for key in self.config.options('Channels_List'):
# Handle categorized channels like "sports.sounders" -> "sounders"
if '.' in key:
channel_name = key.split('.')[-1] # Get part after last dot
else:
channel_name = key
channel_name = channel_name.strip().lower()
if channel_name:
channels.add(channel_name)
except Exception as e:
self.logger.error(f"Error reading decode channels config: {e}")
return list(channels)
def _get_channel_number(self, channel_name):
"""Get channel number from channel name"""
# This would use channel_manager
+5
View File
@@ -84,6 +84,7 @@ class BotIntegration:
import sqlite3
import json
import time
from datetime import datetime
# Ensure packet_data is a dict (might be passed as dict already)
if not isinstance(packet_data, dict):
@@ -100,6 +101,10 @@ class BotIntegration:
# If no path_len either, default to 0 hops
packet_data['hops'] = 0
# Add datetime for frontend display
if 'datetime' not in packet_data:
packet_data['datetime'] = datetime.now().isoformat()
# Convert non-serializable objects to strings
serializable_data = self._make_json_serializable(packet_data)
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -444,7 +444,10 @@
<!-- Chart.js for graphs -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- SHA-256 for channel key derivation (works in non-HTTPS contexts) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
<!-- Base JavaScript -->
<script>
// Global connection management
+2 -1
View File
@@ -164,7 +164,8 @@ class RadioManager {
try {
const response = await fetch('/api/channels');
const data = await response.json();
this.channels = data.channels || [];
// Filter out decode-only channels (they're for packet decoding, not radio management)
this.channels = (data.channels || []).filter(c => !c.decode_only);
this.renderChannels();
this.updateChannelIndexDisplay();
} catch (error) {
File diff suppressed because it is too large Load Diff