feat: Implement full channel management functionality

Add comprehensive channel management features to mc-webui:
- Create new channels with auto-generated encryption keys
- Share channels via QR code or copy-to-clipboard
- Join existing channels with name and key
- Switch between channels in chat interface
- Filter messages by channel
- Persistent channel selection (localStorage)

Backend changes:
- Add CLI wrapper functions: get_channels, add_channel, set_channel, remove_channel
- Modify send_message() to support channel targeting
- Parametrize parser channel filtering (channel_idx parameter)
- Add QR code generation with qrcode + Pillow libraries

API endpoints:
- GET /api/channels - List all channels
- POST /api/channels - Create new channel
- POST /api/channels/join - Join existing channel (auto-detect free slot)
- DELETE /api/channels/<index> - Remove channel
- GET /api/channels/<index>/qr - Generate QR code (JSON or PNG)
- Modified GET /api/messages - Add channel_idx filtering
- Modified POST /api/messages - Add channel_idx targeting

Frontend changes:
- Add channel selector dropdown in navbar
- Add Channels Management modal (create, join, list)
- Add Share Channel modal (QR code, copy key)
- Implement JavaScript channel management logic
- Add event handlers for channel switching
- Persist selected channel in localStorage

QR code format:
{"type":"meshcore_channel","name":"...","key":"..."}

Protection:
- Block deletion of Public channel (index 0)
- Validate channel names (alphanumeric, _, - only)
- Validate encryption keys (32 hex chars)
- Auto-detect free channel slots (1-7)

Backward compatibility:
- Default channel_idx=0 (Public) in all functions
- Existing Public-only code continues to work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2025-12-22 19:04:00 +01:00
parent 0e5c685cfd
commit 761e4eac25
8 changed files with 976 additions and 28 deletions

View File

@@ -10,8 +10,10 @@ A lightweight web interface for meshcore-cli, providing browser-based access to
### Key Features
- 📱 **View messages** - Display chat history from Public channel with auto-refresh
- ✉️ **Send messages** - Publish to Public channel (200 char limit for LoRa)
- 📱 **View messages** - Display chat history with auto-refresh
- ✉️ **Send messages** - Publish to any channel (200 char limit for LoRa)
- 📡 **Channel management** - Create, join, and switch between encrypted channels
- 🔐 **Channel sharing** - Share channels via QR code or encrypted keys
- 💬 **Reply to users** - Quick reply with `@[UserName]` format
- 🧹 **Clean contacts** - Remove inactive contacts with configurable threshold
- 📦 **Message archiving** - Automatic daily archiving with browse-by-date selector
@@ -142,15 +144,39 @@ See [PRD.md](PRD.md) for detailed requirements and implementation plan.
### Viewing Messages
The main page displays chat history from the Public channel (channel 0). Messages auto-refresh every 60 seconds by default.
The main page displays chat history from the currently selected channel. Messages auto-refresh every 60 seconds by default.
By default, the live view shows messages from the last 7 days. Older messages are automatically archived and can be accessed via the date selector.
### Managing Channels
Access channel management by clicking the broadcast icon (📡) in the navbar:
#### Creating a New Channel
1. Click "Add New Channel"
2. Enter a channel name (letters, numbers, _ and - only)
3. Click "Create & Auto-generate Key"
4. The channel is created with a secure encryption key
#### Sharing a Channel
1. In the Channels modal, click the share icon next to any channel
2. Share the QR code (scan with another device) or copy the encryption key
3. Others can join using the "Join Existing" option
#### Joining a Channel
1. Click "Join Existing"
2. Enter the channel name and encryption key (received from channel creator)
3. Click "Join Channel"
4. The channel will be added to your available channels
#### Switching Channels
Use the channel selector dropdown in the navbar to switch between channels. Your selection is remembered between sessions.
### Viewing Message Archives
Access historical messages using the date selector in the navbar:
1. Click the date dropdown in the navbar (next to Refresh button)
1. Click the date dropdown in the navbar
2. Select a date to view archived messages for that day
3. Select "Today (Live)" to return to live view
@@ -158,9 +184,10 @@ Archives are created automatically at midnight (00:00 UTC) each day. The live vi
### Sending Messages
1. Type your message in the text field at the bottom
2. Press Enter or click "Send"
3. Your message will be published to the Public channel
1. Select your target channel using the channel selector
2. Type your message in the text field at the bottom
3. Press Enter or click "Send"
4. Your message will be published to the selected channel
### Replying to Users

View File

@@ -4,7 +4,8 @@ MeshCore CLI wrapper - executes meshcli commands via subprocess
import subprocess
import logging
from typing import Tuple, Optional
import re
from typing import Tuple, Optional, List, Dict
from app.config import config
logger = logging.getLogger(__name__)
@@ -75,13 +76,14 @@ def recv_messages() -> Tuple[bool, str]:
return success, stdout or stderr
def send_message(text: str, reply_to: Optional[str] = None) -> Tuple[bool, str]:
def send_message(text: str, reply_to: Optional[str] = None, channel_index: int = 0) -> Tuple[bool, str]:
"""
Send a message to the Public channel.
Send a message to a specific channel.
Args:
text: Message content
reply_to: Optional username to reply to (will format as @[username])
channel_index: Channel to send to (default: 0 = Public)
Returns:
Tuple of (success, message)
@@ -91,7 +93,13 @@ def send_message(text: str, reply_to: Optional[str] = None) -> Tuple[bool, str]:
else:
message = text
success, stdout, stderr = _run_command(['public', message])
if channel_index == 0:
# Public channel - backward compatibility
success, stdout, stderr = _run_command(['public', message])
else:
# Other channels - use 'chan' command
success, stdout, stderr = _run_command(['chan', str(channel_index), message])
return success, stdout or stderr
@@ -144,3 +152,107 @@ def check_connection() -> bool:
"""
success, _, _ = _run_command(['infos'], timeout=5)
return success
def get_channels() -> Tuple[bool, List[Dict]]:
"""
Get list of configured channels.
Returns:
Tuple of (success, list of channel dicts)
Each dict: {
'index': int,
'name': str,
'key': str
}
"""
success, stdout, stderr = _run_command(['get_channels'])
if not success:
return False, []
channels = []
for line in stdout.split('\n'):
line = line.strip()
if not line:
continue
# Parse: "0: Public [8b3387e9c5cdea6ac9e5edbaa115cd72]"
match = re.match(r'^(\d+):\s+(.+?)\s+\[([a-f0-9]{32})\]$', line)
if match:
channels.append({
'index': int(match.group(1)),
'name': match.group(2),
'key': match.group(3)
})
return True, channels
def add_channel(name: str) -> Tuple[bool, str, Optional[str]]:
"""
Add a new channel with auto-generated key.
Args:
name: Channel name
Returns:
Tuple of (success, message, key_or_none)
key_or_none: The generated key if successful, None otherwise
"""
success, stdout, stderr = _run_command(['add_channel', name])
if not success:
return False, stderr or stdout, None
# Get channels to find the newly created one
success_ch, channels = get_channels()
if success_ch:
for ch in channels:
if ch['name'] == name:
return True, f"Channel '{name}' created", ch['key']
return True, stdout or stderr, None
def set_channel(index: int, name: str, key: str) -> Tuple[bool, str]:
"""
Set/join a channel at specific index with name and key.
Args:
index: Channel slot number
name: Channel name
key: 32-char hex key
Returns:
Tuple of (success, message)
"""
# Validate key format
if not re.match(r'^[a-f0-9]{32}$', key.lower()):
return False, "Invalid key format (must be 32 hex characters)"
success, stdout, stderr = _run_command([
'set_channel',
str(index),
name,
key.lower()
])
return success, stdout or stderr
def remove_channel(index: int) -> Tuple[bool, str]:
"""
Remove a channel.
Args:
index: Channel number to remove
Returns:
Tuple of (success, message)
"""
if index == 0:
return False, "Cannot remove Public channel (channel 0)"
success, stdout, stderr = _run_command(['remove_channel', str(index)])
return success, stdout or stderr

View File

@@ -12,12 +12,13 @@ from app.config import config
logger = logging.getLogger(__name__)
def parse_message(line: Dict) -> Optional[Dict]:
def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> Optional[Dict]:
"""
Parse a single message line from .msgs file.
Args:
line: Raw JSON object from .msgs file
allowed_channels: List of channel indices to include (None = all channels)
Returns:
Parsed message dict or None if not a valid chat message
@@ -25,8 +26,8 @@ def parse_message(line: Dict) -> Optional[Dict]:
msg_type = line.get('type')
channel_idx = line.get('channel_idx', 0)
# Only process Public channel (channel 0) messages
if channel_idx != 0:
# Filter by allowed channels
if allowed_channels is not None and channel_idx not in allowed_channels:
return None
# Only process CHAN (received) and SENT_CHAN (sent) messages
@@ -65,11 +66,12 @@ def parse_message(line: Dict) -> Optional[Dict]:
'datetime': datetime.fromtimestamp(timestamp).isoformat() if timestamp > 0 else None,
'is_own': is_own,
'snr': line.get('SNR'),
'path_len': line.get('path_len')
'path_len': line.get('path_len'),
'channel_idx': channel_idx
}
def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Optional[str] = None, days: Optional[int] = None) -> List[Dict]:
def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Optional[str] = None, days: Optional[int] = None, channel_idx: Optional[int] = None) -> List[Dict]:
"""
Read and parse messages from .msgs file or archive file.
@@ -78,13 +80,14 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op
offset: Number of messages to skip from the end
archive_date: If provided, read from archive file for this date (YYYY-MM-DD)
days: If provided, filter messages from the last N days (only for live .msgs)
channel_idx: Filter messages by channel (None = all channels)
Returns:
List of parsed message dictionaries, sorted by timestamp (oldest first)
"""
# If archive_date is provided, read from archive
if archive_date:
return read_archive_messages(archive_date, limit, offset)
return read_archive_messages(archive_date, limit, offset, channel_idx)
msgs_file = config.msgs_file_path
@@ -92,6 +95,9 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op
logger.warning(f"Messages file not found: {msgs_file}")
return []
# Determine allowed channels
allowed_channels = [channel_idx] if channel_idx is not None else None
messages = []
try:
@@ -103,7 +109,7 @@ def read_messages(limit: Optional[int] = None, offset: int = 0, archive_date: Op
try:
data = json.loads(line)
parsed = parse_message(data)
parsed = parse_message(data, allowed_channels=allowed_channels)
if parsed:
messages.append(parsed)
except json.JSONDecodeError as e:
@@ -159,7 +165,7 @@ def count_messages() -> int:
return len(read_messages())
def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset: int = 0) -> List[Dict]:
def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset: int = 0, channel_idx: Optional[int] = None) -> List[Dict]:
"""
Read messages from an archive file.
@@ -167,6 +173,7 @@ def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset
archive_date: Archive date in YYYY-MM-DD format
limit: Maximum number of messages to return (None = all)
offset: Number of messages to skip from the end
channel_idx: Filter messages by channel (None = all channels)
Returns:
List of parsed message dictionaries, sorted by timestamp (oldest first)
@@ -179,6 +186,9 @@ def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset
logger.warning(f"Archive file not found: {archive_file}")
return []
# Determine allowed channels
allowed_channels = [channel_idx] if channel_idx is not None else None
messages = []
try:
@@ -190,7 +200,7 @@ def read_archive_messages(archive_date: str, limit: Optional[int] = None, offset
try:
data = json.loads(line)
parsed = parse_message(data)
parsed = parse_message(data, allowed_channels=allowed_channels)
if parsed:
messages.append(parsed)
except json.JSONDecodeError as e:

View File

@@ -3,8 +3,12 @@ REST API endpoints for mc-webui
"""
import logging
import json
import re
import base64
from datetime import datetime
from flask import Blueprint, jsonify, request
from io import BytesIO
from flask import Blueprint, jsonify, request, send_file
from app.meshcore import cli, parser
from app.config import config
from app.archiver import manager as archive_manager
@@ -17,13 +21,14 @@ api_bp = Blueprint('api', __name__, url_prefix='/api')
@api_bp.route('/messages', methods=['GET'])
def get_messages():
"""
Get list of messages from Public channel or archive.
Get list of messages from specific channel or archive.
Query parameters:
limit (int): Maximum number of messages to return
offset (int): Number of messages to skip from the end
archive_date (str): View archive for specific date (YYYY-MM-DD format)
days (int): Show only messages from last N days (live view only)
channel_idx (int): Filter by channel index (optional)
Returns:
JSON with messages list
@@ -33,6 +38,7 @@ def get_messages():
offset = request.args.get('offset', default=0, type=int)
archive_date = request.args.get('archive_date', type=str)
days = request.args.get('days', type=int)
channel_idx = request.args.get('channel_idx', type=int)
# Validate archive_date format if provided
if archive_date:
@@ -49,14 +55,16 @@ def get_messages():
limit=limit,
offset=offset,
archive_date=archive_date,
days=days
days=days,
channel_idx=channel_idx
)
return jsonify({
'success': True,
'count': len(messages),
'messages': messages,
'archive_date': archive_date if archive_date else None
'archive_date': archive_date if archive_date else None,
'channel_idx': channel_idx
}), 200
except Exception as e:
@@ -70,11 +78,12 @@ def get_messages():
@api_bp.route('/messages', methods=['POST'])
def send_message():
"""
Send a message to the Public channel.
Send a message to a specific channel.
JSON body:
text (str): Message content (required)
reply_to (str): Username to reply to (optional)
channel_idx (int): Channel to send to (optional, default: 0)
Returns:
JSON with success status
@@ -105,14 +114,16 @@ def send_message():
}), 400
reply_to = data.get('reply_to')
channel_idx = data.get('channel_idx', 0)
# Send message via meshcli
success, message = cli.send_message(text, reply_to=reply_to)
success, message = cli.send_message(text, reply_to=reply_to, channel_index=channel_idx)
if success:
return jsonify({
'success': True,
'message': 'Message sent successfully'
'message': 'Message sent successfully',
'channel_idx': channel_idx
}), 200
else:
return jsonify({
@@ -336,3 +347,299 @@ def trigger_archive():
'success': False,
'error': str(e)
}), 500
@api_bp.route('/channels', methods=['GET'])
def get_channels():
"""
Get list of configured channels.
Returns:
JSON with channels list
"""
try:
success, channels = cli.get_channels()
if success:
return jsonify({
'success': True,
'channels': channels,
'count': len(channels)
}), 200
else:
return jsonify({
'success': False,
'error': 'Failed to retrieve channels'
}), 500
except Exception as e:
logger.error(f"Error getting channels: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_bp.route('/channels', methods=['POST'])
def create_channel():
"""
Create a new channel with auto-generated key.
JSON body:
name (str): Channel name (required)
Returns:
JSON with created channel info
"""
try:
data = request.get_json()
if not data or 'name' not in data:
return jsonify({
'success': False,
'error': 'Missing required field: name'
}), 400
name = data['name'].strip()
if not name:
return jsonify({
'success': False,
'error': 'Channel name cannot be empty'
}), 400
# Validate name (no special chars that could break CLI)
if not re.match(r'^[a-zA-Z0-9_\-]+$', name):
return jsonify({
'success': False,
'error': 'Channel name can only contain letters, numbers, _ and -'
}), 400
success, message, key = cli.add_channel(name)
if success:
return jsonify({
'success': True,
'message': message,
'channel': {
'name': name,
'key': key
}
}), 201
else:
return jsonify({
'success': False,
'error': message
}), 500
except Exception as e:
logger.error(f"Error creating channel: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_bp.route('/channels/join', methods=['POST'])
def join_channel():
"""
Join an existing channel by setting name and key.
JSON body:
name (str): Channel name (required)
key (str): 32-char hex key (required)
index (int): Channel slot (optional, auto-detect if not provided)
Returns:
JSON with result
"""
try:
data = request.get_json()
if not data or 'name' not in data or 'key' not in data:
return jsonify({
'success': False,
'error': 'Missing required fields: name, key'
}), 400
name = data['name'].strip()
key = data['key'].strip().lower()
# Auto-detect free slot if not provided
if 'index' in data:
index = int(data['index'])
else:
# Find first free slot (1-7, skip 0 which is Public)
success_ch, channels = cli.get_channels()
if not success_ch:
return jsonify({
'success': False,
'error': 'Failed to get current channels'
}), 500
used_indices = {ch['index'] for ch in channels}
index = None
for i in range(1, 8): # Assume max 8 channels
if i not in used_indices:
index = i
break
if index is None:
return jsonify({
'success': False,
'error': 'No free channel slots available'
}), 400
success, message = cli.set_channel(index, name, key)
if success:
return jsonify({
'success': True,
'message': f'Joined channel "{name}" at slot {index}',
'channel': {
'index': index,
'name': name,
'key': key
}
}), 200
else:
return jsonify({
'success': False,
'error': message
}), 500
except Exception as e:
logger.error(f"Error joining channel: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_bp.route('/channels/<int:index>', methods=['DELETE'])
def delete_channel(index):
"""
Remove a channel.
Args:
index: Channel index to remove
Returns:
JSON with result
"""
try:
success, message = cli.remove_channel(index)
if success:
return jsonify({
'success': True,
'message': f'Channel {index} removed'
}), 200
else:
return jsonify({
'success': False,
'error': message
}), 500
except Exception as e:
logger.error(f"Error removing channel: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_bp.route('/channels/<int:index>/qr', methods=['GET'])
def get_channel_qr(index):
"""
Generate QR code for channel sharing.
Args:
index: Channel index
Query params:
format: 'json' (default) or 'png'
Returns:
JSON with QR data or PNG image
"""
try:
import qrcode
# Get channel info
success, channels = cli.get_channels()
if not success:
return jsonify({
'success': False,
'error': 'Failed to get channels'
}), 500
channel = next((ch for ch in channels if ch['index'] == index), None)
if not channel:
return jsonify({
'success': False,
'error': f'Channel {index} not found'
}), 404
# Create QR data
qr_data = {
'type': 'meshcore_channel',
'name': channel['name'],
'key': channel['key']
}
qr_json = json.dumps(qr_data)
format_type = request.args.get('format', 'json')
if format_type == 'png':
# Generate PNG QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(qr_json)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to PNG bytes
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return send_file(buf, mimetype='image/png')
else: # JSON format
# Generate base64 data URL for inline display
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(qr_json)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
img_base64 = base64.b64encode(buf.read()).decode()
data_url = f"data:image/png;base64,{img_base64}"
return jsonify({
'success': True,
'qr_data': qr_data,
'qr_image': data_url,
'qr_text': qr_json
}), 200
except ImportError:
return jsonify({
'success': False,
'error': 'QR code library not available'
}), 500
except Exception as e:
logger.error(f"Error generating QR code: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500

View File

@@ -7,11 +7,22 @@ let lastMessageCount = 0;
let autoRefreshInterval = null;
let isUserScrolling = false;
let currentArchiveDate = null; // Current selected archive date (null = live)
let currentChannelIdx = 0; // Current active channel (0 = Public)
let availableChannels = []; // List of channels from API
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('mc-webui initialized');
// Load channels list
loadChannels();
// Restore last selected channel from localStorage
const savedChannel = localStorage.getItem('mc_active_channel');
if (savedChannel !== null) {
currentChannelIdx = parseInt(savedChannel);
}
// Load archive list
loadArchiveList();
@@ -85,6 +96,95 @@ function setupEventListeners() {
settingsModal.addEventListener('show.bs.modal', function() {
loadDeviceInfo();
});
// Channel selector
document.getElementById('channelSelector').addEventListener('change', function(e) {
currentChannelIdx = parseInt(e.target.value);
localStorage.setItem('mc_active_channel', currentChannelIdx);
loadMessages();
const channelName = e.target.options[e.target.selectedIndex].text;
showNotification(`Switched to channel: ${channelName}`, 'info');
});
// Channels modal - load channels when opened
const channelsModal = document.getElementById('channelsModal');
channelsModal.addEventListener('show.bs.modal', function() {
loadChannelsList();
});
// Create channel form
document.getElementById('createChannelForm').addEventListener('submit', async function(e) {
e.preventDefault();
const name = document.getElementById('newChannelName').value.trim();
try {
const response = await fetch('/api/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
const data = await response.json();
if (data.success) {
showNotification(`Channel "${name}" created!`, 'success');
document.getElementById('newChannelName').value = '';
document.getElementById('addChannelForm').classList.remove('show');
// Reload channels
await loadChannels();
loadChannelsList();
} else {
showNotification('Failed to create channel: ' + data.error, 'danger');
}
} catch (error) {
showNotification('Failed to create channel', 'danger');
}
});
// Join channel form
document.getElementById('joinChannelFormSubmit').addEventListener('submit', async function(e) {
e.preventDefault();
const name = document.getElementById('joinChannelName').value.trim();
const key = document.getElementById('joinChannelKey').value.trim().toLowerCase();
try {
const response = await fetch('/api/channels/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name, key: key })
});
const data = await response.json();
if (data.success) {
showNotification(`Joined channel "${name}"!`, 'success');
document.getElementById('joinChannelName').value = '';
document.getElementById('joinChannelKey').value = '';
document.getElementById('joinChannelForm').classList.remove('show');
// Reload channels
await loadChannels();
loadChannelsList();
} else {
showNotification('Failed to join channel: ' + data.error, 'danger');
}
} catch (error) {
showNotification('Failed to join channel', 'danger');
}
});
// Scan QR button (placeholder)
document.getElementById('scanQRBtn').addEventListener('click', function() {
showNotification('QR scanning feature coming soon! For now, manually enter the channel details.', 'info');
});
}
/**
@@ -95,6 +195,9 @@ async function loadMessages() {
// Build URL with appropriate parameters
let url = '/api/messages?limit=500';
// Add channel filter
url += `&channel_idx=${currentChannelIdx}`;
if (currentArchiveDate) {
// Loading archive
url += `&archive_date=${currentArchiveDate}`;
@@ -205,7 +308,10 @@ async function sendMessage() {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text: text })
body: JSON.stringify({
text: text,
channel_idx: currentChannelIdx
})
});
const data = await response.json();
@@ -531,3 +637,190 @@ function setupEmojiPicker() {
}
});
}
/**
* Load list of available channels
*/
async function loadChannels() {
try {
const response = await fetch('/api/channels');
const data = await response.json();
if (data.success) {
availableChannels = data.channels;
populateChannelSelector(data.channels);
} else {
console.error('Error loading channels:', data.error);
}
} catch (error) {
console.error('Error loading channels:', error);
}
}
/**
* Populate channel selector dropdown
*/
function populateChannelSelector(channels) {
const selector = document.getElementById('channelSelector');
// Clear current options
selector.innerHTML = '';
// Add channels
channels.forEach(channel => {
const option = document.createElement('option');
option.value = channel.index;
option.textContent = channel.name;
selector.appendChild(option);
});
// Set current selection
selector.value = currentChannelIdx;
console.log(`Loaded ${channels.length} channels`);
}
/**
* Load channels list in management modal
*/
async function loadChannelsList() {
const listEl = document.getElementById('channelsList');
listEl.innerHTML = '<div class="text-center text-muted py-3"><div class="spinner-border spinner-border-sm"></div> Loading...</div>';
try {
const response = await fetch('/api/channels');
const data = await response.json();
if (data.success) {
displayChannelsList(data.channels);
} else {
listEl.innerHTML = '<div class="alert alert-danger">Error loading channels</div>';
}
} catch (error) {
listEl.innerHTML = '<div class="alert alert-danger">Failed to load channels</div>';
}
}
/**
* Display channels in management modal
*/
function displayChannelsList(channels) {
const listEl = document.getElementById('channelsList');
if (channels.length === 0) {
listEl.innerHTML = '<div class="text-muted text-center py-3">No channels configured</div>';
return;
}
listEl.innerHTML = '';
channels.forEach(channel => {
const item = document.createElement('div');
item.className = 'list-group-item d-flex justify-content-between align-items-center';
const isPublic = channel.index === 0;
item.innerHTML = `
<div>
<strong>${escapeHtml(channel.name)}</strong>
<br>
<small class="text-muted font-monospace">${channel.key}</small>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="shareChannel(${channel.index})" title="Share">
<i class="bi bi-share"></i>
</button>
${!isPublic ? `
<button class="btn btn-outline-danger" onclick="deleteChannel(${channel.index})" title="Delete">
<i class="bi bi-trash"></i>
</button>
` : ''}
</div>
`;
listEl.appendChild(item);
});
}
/**
* Delete channel
*/
async function deleteChannel(index) {
const channel = availableChannels.find(ch => ch.index === index);
if (!channel) return;
if (!confirm(`Remove channel "${channel.name}"?`)) {
return;
}
try {
const response = await fetch(`/api/channels/${index}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showNotification(`Channel "${channel.name}" removed`, 'success');
// If deleted current channel, switch to Public
if (currentChannelIdx === index) {
currentChannelIdx = 0;
localStorage.setItem('mc_active_channel', '0');
loadMessages();
}
// Reload channels
await loadChannels();
loadChannelsList();
} else {
showNotification('Failed to remove channel: ' + data.error, 'danger');
}
} catch (error) {
showNotification('Failed to remove channel', 'danger');
}
}
/**
* Share channel (show QR code)
*/
async function shareChannel(index) {
try {
const response = await fetch(`/api/channels/${index}/qr`);
const data = await response.json();
if (data.success) {
// Populate share modal
document.getElementById('shareChannelName').textContent = `Channel: ${data.qr_data.name}`;
document.getElementById('shareChannelQR').src = data.qr_image;
document.getElementById('shareChannelKey').value = data.qr_data.key;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('shareChannelModal'));
modal.show();
} else {
showNotification('Failed to generate QR code: ' + data.error, 'danger');
}
} catch (error) {
showNotification('Failed to generate QR code', 'danger');
}
}
/**
* Copy channel key to clipboard
*/
function copyChannelKey() {
const input = document.getElementById('shareChannelKey');
input.select();
document.execCommand('copy');
showNotification('Channel key copied to clipboard!', 'success');
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

View File

@@ -36,6 +36,13 @@
<button class="btn btn-outline-light btn-sm" id="refreshBtn" title="Refresh messages">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<select id="channelSelector" class="form-select form-select-sm" style="width: auto; min-width: 120px;" title="Select channel">
<option value="0">Public</option>
<!-- Channels loaded dynamically via JavaScript -->
</select>
<button class="btn btn-outline-light btn-sm" data-bs-toggle="modal" data-bs-target="#channelsModal" title="Manage Channels">
<i class="bi bi-broadcast-pin"></i>
</button>
<select id="dateSelector" class="form-select form-select-sm" style="width: auto; min-width: 150px;" title="Select date">
<option value="">Today (Live)</option>
<!-- Archive dates loaded dynamically via JavaScript -->
@@ -52,6 +59,122 @@
{% block content %}{% endblock %}
</main>
<!-- Channels Management Modal -->
<div class="modal fade" id="channelsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-broadcast-pin"></i> Manage Channels</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Channel List -->
<h6>Your Channels</h6>
<div id="channelsList" class="list-group mb-3">
<div class="text-center text-muted py-3">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
<!-- Actions -->
<div class="btn-group w-100 mb-3" role="group">
<button type="button" class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#addChannelForm">
<i class="bi bi-plus-circle"></i> Add New Channel
</button>
<button type="button" class="btn btn-success" data-bs-toggle="collapse" data-bs-target="#joinChannelForm">
<i class="bi bi-box-arrow-in-right"></i> Join Existing
</button>
</div>
<!-- Add Channel Form (collapsed) -->
<div class="collapse" id="addChannelForm">
<div class="card card-body mb-3">
<h6>Create New Channel</h6>
<form id="createChannelForm">
<div class="mb-2">
<label for="newChannelName" class="form-label">Channel Name</label>
<input type="text" class="form-control" id="newChannelName"
placeholder="e.g., Malopolska"
pattern="[a-zA-Z0-9_\-]+"
required>
<small class="text-muted">Only letters, numbers, _ and -</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create & Auto-generate Key
</button>
</form>
</div>
</div>
<!-- Join Channel Form (collapsed) -->
<div class="collapse" id="joinChannelForm">
<div class="card card-body mb-3">
<h6>Join Existing Channel</h6>
<form id="joinChannelFormSubmit">
<div class="mb-2">
<label for="joinChannelName" class="form-label">Channel Name</label>
<input type="text" class="form-control" id="joinChannelName" required>
</div>
<div class="mb-2">
<label for="joinChannelKey" class="form-label">Channel Key (32 hex chars)</label>
<input type="text" class="form-control" id="joinChannelKey"
placeholder="485af7e164459d280d8818d9c99fb30d"
pattern="[a-fA-F0-9]{32}"
maxlength="32"
required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="bi bi-box-arrow-in-right"></i> Join Channel
</button>
<button type="button" class="btn btn-secondary" id="scanQRBtn">
<i class="bi bi-qr-code-scan"></i> Scan QR Code
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Share Channel Modal -->
<div class="modal fade" id="shareChannelModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-share"></i> Share Channel</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 id="shareChannelName"></h6>
<!-- QR Code -->
<div class="text-center mb-3">
<img id="shareChannelQR" src="" alt="QR Code" class="img-fluid" style="max-width: 300px;">
</div>
<!-- Channel Details -->
<div class="mb-3">
<label class="form-label fw-bold">Channel Key:</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="shareChannelKey" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyChannelKey()">
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle"></i>
Share this QR code or key with others to let them join this channel.
</div>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1">
<div class="modal-dialog">

View File

@@ -16,5 +16,9 @@ APScheduler==3.10.4
# Date/Time Utilities
python-dateutil==2.8.2
# QR Code Generation
qrcode==7.4.2
Pillow==10.1.0
# Additional utilities (if needed later)
# requests==2.31.0 # For future API integrations

72
technotes/channels.md Normal file
View File

@@ -0,0 +1,72 @@
## Pytanie do Gemini:
```
Jak działają kanały meshcore? Załóżmy, że dodam nowy kanał:
$ meshcli -s /dev/serial/by-id/usb-Espressif_Systems_heltec_wifi_lora_32_v4__16_MB_FLASH__2_MB_PSRAM__90706984A000-if00 add_channel Malopolska
INFO:meshcore:Serial Connection started
INFO:meshcore:Connected to MarWoj running on a v1.11.0-6d32193 fw.
INFO:meshcore:Executing device init script : /home/marek/.config/meshcore/MarWoj.init
Potem widzę ten kanał za pomocą komenendy get_channels:
$ meshcli -s /dev/serial/by-id/usb-Espressif_Systems_heltec_wifi_lora_32_v4__16_MB_FLASH__2_MB_PSRAM__90706984A000-if00 get_channels
INFO:meshcore:Serial Connection started
INFO:meshcore:Connected to MarWoj running on a v1.11.0-6d32193 fw.
INFO:meshcore:Executing device init script : /home/marek/.config/meshcore/MarWoj.init
0: Public [8b3387e9c5cdea6ac9e5edbaa115cd72]
1: Malopolska [485af7e164459d280d8818d9c99fb30d]
W jaki sposób mogę użyć kanału? Jak zaprosić do niego innych użytkowników?
```
## Odpowiedź
To, co zrobiłeś, to stworzenie **prywatnego, szyfrowanego "pokoju"**. W świecie MeshCore (i ogólnie LoRa) kanały nie działają jak grupy na Facebooku czy Discordzie, gdzie klikasz "zaproś". Tutaj kluczem do wspólnej rozmowy jest **wspólny klucz kryptograficzny**.
Oto instrukcja, jak korzystać z nowego kanału i jak "wpuścić" do niego znajomych, oparta na dokumentacji `meshcore-cli`.
### 1. Jak używać kanału? (Wysyłanie wiadomości)
Masz teraz dwa kanały:
* **0: Public** (Otwarty dla wszystkich).
* **1: Malopolska** (Twój prywatny, szyfrowany).
Aby napisać wiadomość na nowym kanale, masz dwie metody:
**Metoda A: Tryb interaktywny (Zalecana)**
Będąc w trybie czatu (`mc chat` lub po prostu uruchomieniu bez argumentów), musisz przełączyć się na ten kanał komendą `to`.
```bash
to Malopolska
```
(lub `to 1`, jeśli nazwa nie zadziała).
Od teraz wszystko, co wpiszesz i zatwierdzisz Enterem, poleci na kanał "Malopolska". Aby wrócić na ogólny, wpisz `to Public` lub `to 0`.
**Metoda B: "Z zewnątrz" (One-Liner)**
Jeśli chcesz wysłać szybką wiadomość bez wchodzenia w tryb czatu, użyj komendy `chan` z numerem kanału:
```bash
meshcli chan 1 "Cześć, to test kanału Malopolska"
```
### 2. Jak zaprosić innych użytkowników?
To najważniejszy punkt. Ponieważ MeshCore jest siecią zdecentralizowaną, nie ma serwera, który wyśle zaproszenie. Musisz **ręcznie przekazać klucz** swojemu rozmówcy inną drogą (np. SMSem, na Signal, czy podając kartkę z kodem).
Klucz Twojego kanału to ten ciąg znaków w nawiasie kwadratowym, który pokazał Ci się w logu:
`485af7e164459d280d8818d9c99fb30d`
**Instrukcja dla Twojego znajomego:**
Twój kolega musi skonfigurować swój kanał nr 1 (lub inny wolny slot) tak, aby miał **identyczną nazwę i identyczny klucz**. Jeśli użyje samej komendy `add_channel`, jego radio wygeneruje losowy klucz i nie będziecie się słyszeć (zobaczy tylko "krzaki" lub nic).
Musi użyć komendy `set_channel`, która przyjmuje format: `set_channel <numer> <nazwa> <klucz>`.
Niech Twój znajomy wpisze u siebie:
```bash
set_channel 1 Malopolska 485af7e164459d280d8818d9c99fb30d
```
### Podsumowanie techniczne
* **Szyfrowanie:** Tylko radia posiadające ten konkretny klucz (`485af...`) będą w stanie odszyfrować treść wiadomości. Dla wszystkich innych użytkowników sieci pakiety te będą wyglądać jak cyfrowy szum i zostaną zignorowane.
* **Numer slotu:** Nie ma znaczenia, czy Ty masz ten kanał na pozycji `1`, a kolega na pozycji `2`. Ważne, aby **nazwa** i **klucz** były identyczne.
* **Zasięg:** Kanały prywatne nadal korzystają z "floodingu", więc wiadomości będą przenoszone przez repeatery, ale repeatery nie będą w stanie podejrzeć ich treści.