mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
41
README.md
41
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
72
technotes/channels.md
Normal 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.
|
||||
Reference in New Issue
Block a user