mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-02 11:32:35 +02:00
Implemented new "Discover Nodes" feature in Network Commands menu: - Added .node_discover command to meshcli wrapper (cli.py) - Created interactive modal with sortable table showing nearby repeaters - Displays SNR, RSSI, path length, and signal quality indicators - Added refresh functionality to rescan for nodes Fixed advert notification to show clean "Advert sent" message instead of full meshcli output. Technical changes: - app/meshcore/cli.py: Added node_discover() function with JSON parsing - app/routes/api.py: Updated SPECIAL_COMMANDS and execute_special_command() to handle node_discover return type and clean advert message - app/templates/base.html: Added "Discover Nodes" menu button and modal - app/static/js/app.js: Added discoverNodes() and displayNodeDiscoveryResults() - README.md: Added documentation for Discover Nodes feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1975 lines
58 KiB
Python
1975 lines
58 KiB
Python
"""
|
|
REST API endpoints for mc-webui
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import re
|
|
import base64
|
|
import time
|
|
from datetime import datetime
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
|
|
|
# Simple cache for get_channels() to reduce USB/meshcli calls
|
|
# Channels don't change frequently, so caching for 30s is safe
|
|
_channels_cache = None
|
|
_channels_cache_timestamp = 0
|
|
CHANNELS_CACHE_TTL = 30 # seconds
|
|
|
|
|
|
def get_channels_cached(force_refresh=False):
|
|
"""
|
|
Get channels with caching to reduce USB/meshcli calls.
|
|
|
|
Args:
|
|
force_refresh: If True, bypass cache and fetch fresh data
|
|
|
|
Returns:
|
|
Tuple of (success, channels_list)
|
|
"""
|
|
global _channels_cache, _channels_cache_timestamp
|
|
|
|
current_time = time.time()
|
|
|
|
# Return cached data if valid and not forcing refresh
|
|
if (not force_refresh and
|
|
_channels_cache is not None and
|
|
(current_time - _channels_cache_timestamp) < CHANNELS_CACHE_TTL):
|
|
logger.debug(f"Returning cached channels (age: {current_time - _channels_cache_timestamp:.1f}s)")
|
|
return True, _channels_cache
|
|
|
|
# Fetch fresh data
|
|
logger.debug("Fetching fresh channels from meshcli")
|
|
success, channels = cli.get_channels()
|
|
|
|
if success:
|
|
_channels_cache = channels
|
|
_channels_cache_timestamp = current_time
|
|
logger.debug(f"Channels cached ({len(channels)} channels)")
|
|
|
|
return success, channels
|
|
|
|
|
|
def invalidate_channels_cache():
|
|
"""Invalidate channels cache (call after add/remove channel)"""
|
|
global _channels_cache, _channels_cache_timestamp
|
|
_channels_cache = None
|
|
_channels_cache_timestamp = 0
|
|
logger.debug("Channels cache invalidated")
|
|
|
|
|
|
@api_bp.route('/messages', methods=['GET'])
|
|
def get_messages():
|
|
"""
|
|
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
|
|
"""
|
|
try:
|
|
limit = request.args.get('limit', type=int)
|
|
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:
|
|
try:
|
|
datetime.strptime(archive_date, '%Y-%m-%d')
|
|
except ValueError:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Invalid date format: {archive_date}. Expected YYYY-MM-DD'
|
|
}), 400
|
|
|
|
# Read messages (from archive or live .msgs file)
|
|
messages = parser.read_messages(
|
|
limit=limit,
|
|
offset=offset,
|
|
archive_date=archive_date,
|
|
days=days,
|
|
channel_idx=channel_idx
|
|
)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'count': len(messages),
|
|
'messages': messages,
|
|
'archive_date': archive_date if archive_date else None,
|
|
'channel_idx': channel_idx
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching messages: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/messages', methods=['POST'])
|
|
def send_message():
|
|
"""
|
|
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
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data or 'text' not in data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: text'
|
|
}), 400
|
|
|
|
text = data['text'].strip()
|
|
if not text:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Message text cannot be empty'
|
|
}), 400
|
|
|
|
# MeshCore message length limit (~180-200 bytes for LoRa)
|
|
# Count UTF-8 bytes, not Unicode characters
|
|
byte_length = len(text.encode('utf-8'))
|
|
if byte_length > 200:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Message too long ({byte_length} bytes). Maximum 200 bytes allowed due to LoRa constraints.'
|
|
}), 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, channel_index=channel_idx)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Message sent successfully',
|
|
'channel_idx': channel_idx
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error sending message: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/status', methods=['GET'])
|
|
def get_status():
|
|
"""
|
|
Get device connection status and basic info.
|
|
|
|
Returns:
|
|
JSON with status information
|
|
"""
|
|
try:
|
|
# Check if device is accessible
|
|
connected = cli.check_connection()
|
|
|
|
# Get message count
|
|
message_count = parser.count_messages()
|
|
|
|
# Get latest message timestamp
|
|
latest = parser.get_latest_message()
|
|
latest_timestamp = latest['timestamp'] if latest else None
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'connected': connected,
|
|
'device_name': config.MC_DEVICE_NAME,
|
|
'serial_port': config.MC_SERIAL_PORT,
|
|
'message_count': message_count,
|
|
'latest_message_timestamp': latest_timestamp
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting status: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/contacts', methods=['GET'])
|
|
def get_contacts():
|
|
"""
|
|
Get list of contacts from the device.
|
|
|
|
Returns:
|
|
JSON with list of contact names
|
|
"""
|
|
try:
|
|
success, contacts, error = cli.get_contacts_list()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'contacts': contacts,
|
|
'count': len(contacts)
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': error or 'Failed to get contacts',
|
|
'contacts': []
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting contacts: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'contacts': []
|
|
}), 500
|
|
|
|
|
|
def _filter_contacts_by_criteria(contacts: list, criteria: dict) -> list:
|
|
"""
|
|
Filter contacts based on cleanup criteria.
|
|
|
|
Args:
|
|
contacts: List of contact dicts from /api/contacts/detailed
|
|
criteria: Filter criteria:
|
|
- name_filter (str): Partial name match (empty = ignore)
|
|
- types (list[int]): Contact types to include [1,2,3,4]
|
|
- date_field (str): "last_advert" or "lastmod"
|
|
- days (int): Days of inactivity (0 = ignore)
|
|
|
|
Returns:
|
|
List of contacts matching criteria
|
|
"""
|
|
name_filter = criteria.get('name_filter', '').strip().lower()
|
|
selected_types = criteria.get('types', [1, 2, 3, 4])
|
|
date_field = criteria.get('date_field', 'last_advert')
|
|
days = criteria.get('days', 0)
|
|
|
|
# Calculate timestamp threshold for days filter
|
|
current_time = int(time.time())
|
|
days_threshold = days * 86400 # Convert days to seconds
|
|
|
|
filtered = []
|
|
for contact in contacts:
|
|
# Filter by type
|
|
if contact.get('type') not in selected_types:
|
|
continue
|
|
|
|
# Filter by name (partial match, case-insensitive)
|
|
if name_filter:
|
|
contact_name = contact.get('name', '').lower()
|
|
if name_filter not in contact_name:
|
|
continue
|
|
|
|
# Filter by date (days of inactivity)
|
|
if days > 0:
|
|
timestamp = contact.get(date_field, 0)
|
|
if timestamp == 0:
|
|
# No timestamp - consider as inactive
|
|
pass
|
|
else:
|
|
# Check if inactive for more than specified days
|
|
age_seconds = current_time - timestamp
|
|
if age_seconds <= days_threshold:
|
|
# Still active within threshold
|
|
continue
|
|
|
|
# Contact matches all criteria
|
|
filtered.append(contact)
|
|
|
|
return filtered
|
|
|
|
|
|
@api_bp.route('/contacts/preview-cleanup', methods=['POST'])
|
|
def preview_cleanup_contacts():
|
|
"""
|
|
Preview contacts that will be deleted based on filter criteria.
|
|
|
|
JSON body:
|
|
{
|
|
"name_filter": "", # Partial name match (empty = ignore)
|
|
"types": [1, 2, 3, 4], # Contact types (1=CLI, 2=REP, 3=ROOM, 4=SENS)
|
|
"date_field": "last_advert", # "last_advert" or "lastmod"
|
|
"days": 2 # Days of inactivity (0 = ignore)
|
|
}
|
|
|
|
Returns:
|
|
JSON with preview of contacts to be deleted:
|
|
{
|
|
"success": true,
|
|
"contacts": [...],
|
|
"count": 15
|
|
}
|
|
"""
|
|
try:
|
|
data = request.get_json() or {}
|
|
|
|
# Validate criteria
|
|
criteria = {
|
|
'name_filter': data.get('name_filter', ''),
|
|
'types': data.get('types', [1, 2, 3, 4]),
|
|
'date_field': data.get('date_field', 'last_advert'),
|
|
'days': data.get('days', 0)
|
|
}
|
|
|
|
# Validate types
|
|
if not isinstance(criteria['types'], list) or not all(t in [1, 2, 3, 4] for t in criteria['types']):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Invalid types (must be list of 1, 2, 3, 4)'
|
|
}), 400
|
|
|
|
# Validate date_field
|
|
if criteria['date_field'] not in ['last_advert', 'lastmod']:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Invalid date_field (must be "last_advert" or "lastmod")'
|
|
}), 400
|
|
|
|
# Validate numeric fields
|
|
if not isinstance(criteria['days'], int) or criteria['days'] < 0:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Invalid days (must be non-negative integer)'
|
|
}), 400
|
|
|
|
# Get all contacts
|
|
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
|
|
if not success_detailed:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': error_detailed or 'Failed to get contacts'
|
|
}), 500
|
|
|
|
# Convert to list format (same as /api/contacts/detailed)
|
|
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
|
|
contacts = []
|
|
for public_key, details in contacts_detailed.items():
|
|
out_path_len = details.get('out_path_len', -1)
|
|
contacts.append({
|
|
'public_key': public_key,
|
|
'name': details.get('adv_name', ''),
|
|
'type': details.get('type'),
|
|
'type_label': type_labels.get(details.get('type'), 'UNKNOWN'),
|
|
'last_advert': details.get('last_advert'),
|
|
'lastmod': details.get('lastmod'),
|
|
'out_path_len': out_path_len,
|
|
'out_path': details.get('out_path', ''),
|
|
'adv_lat': details.get('adv_lat'),
|
|
'adv_lon': details.get('adv_lon')
|
|
})
|
|
|
|
# Filter contacts
|
|
filtered_contacts = _filter_contacts_by_criteria(contacts, criteria)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'contacts': filtered_contacts,
|
|
'count': len(filtered_contacts)
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error previewing cleanup: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/contacts/cleanup', methods=['POST'])
|
|
def cleanup_contacts():
|
|
"""
|
|
Clean up contacts based on filter criteria.
|
|
|
|
JSON body:
|
|
{
|
|
"name_filter": "", # Partial name match (empty = ignore)
|
|
"types": [1, 2, 3, 4], # Contact types (1=CLI, 2=REP, 3=ROOM, 4=SENS)
|
|
"date_field": "last_advert", # "last_advert" or "lastmod"
|
|
"days": 2 # Days of inactivity (0 = ignore)
|
|
}
|
|
|
|
Returns:
|
|
JSON with cleanup result:
|
|
{
|
|
"success": true,
|
|
"deleted_count": 15,
|
|
"failed_count": 2,
|
|
"failures": [
|
|
{"name": "Contact1", "error": "..."},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
try:
|
|
data = request.get_json() or {}
|
|
|
|
# Validate criteria (same as preview)
|
|
criteria = {
|
|
'name_filter': data.get('name_filter', ''),
|
|
'types': data.get('types', [1, 2, 3, 4]),
|
|
'date_field': data.get('date_field', 'last_advert'),
|
|
'days': data.get('days', 0)
|
|
}
|
|
|
|
# Validate types
|
|
if not isinstance(criteria['types'], list) or not all(t in [1, 2, 3, 4] for t in criteria['types']):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Invalid types (must be list of 1, 2, 3, 4)'
|
|
}), 400
|
|
|
|
# Validate date_field
|
|
if criteria['date_field'] not in ['last_advert', 'lastmod']:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Invalid date_field (must be "last_advert" or "lastmod")'
|
|
}), 400
|
|
|
|
# Validate numeric fields
|
|
if not isinstance(criteria['days'], int) or criteria['days'] < 0:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Invalid days (must be non-negative integer)'
|
|
}), 400
|
|
|
|
# Get all contacts
|
|
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
|
|
if not success_detailed:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': error_detailed or 'Failed to get contacts'
|
|
}), 500
|
|
|
|
# Convert to list format
|
|
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
|
|
contacts = []
|
|
for public_key, details in contacts_detailed.items():
|
|
out_path_len = details.get('out_path_len', -1)
|
|
contacts.append({
|
|
'public_key': public_key,
|
|
'name': details.get('adv_name', ''),
|
|
'type': details.get('type'),
|
|
'type_label': type_labels.get(details.get('type'), 'UNKNOWN'),
|
|
'last_advert': details.get('last_advert'),
|
|
'lastmod': details.get('lastmod'),
|
|
'out_path_len': out_path_len
|
|
})
|
|
|
|
# Filter contacts to delete
|
|
filtered_contacts = _filter_contacts_by_criteria(contacts, criteria)
|
|
|
|
if len(filtered_contacts) == 0:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'No contacts matched the criteria',
|
|
'deleted_count': 0,
|
|
'failed_count': 0,
|
|
'failures': []
|
|
}), 200
|
|
|
|
# Delete contacts one by one, track failures
|
|
deleted_count = 0
|
|
failed_count = 0
|
|
failures = []
|
|
|
|
for contact in filtered_contacts:
|
|
contact_name = contact['name']
|
|
success, message = cli.delete_contact(contact_name)
|
|
|
|
if success:
|
|
deleted_count += 1
|
|
else:
|
|
failed_count += 1
|
|
failures.append({
|
|
'name': contact_name,
|
|
'error': message
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Cleanup completed: {deleted_count} deleted, {failed_count} failed',
|
|
'deleted_count': deleted_count,
|
|
'failed_count': failed_count,
|
|
'failures': failures
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error cleaning contacts: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/device/info', methods=['GET'])
|
|
def get_device_info():
|
|
"""
|
|
Get detailed device information.
|
|
|
|
Returns:
|
|
JSON with device info
|
|
"""
|
|
try:
|
|
success, info = cli.get_device_info()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'info': info
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': info
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting device info: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Special Commands
|
|
# =============================================================================
|
|
|
|
# Registry of available special commands
|
|
SPECIAL_COMMANDS = {
|
|
'advert': {
|
|
'function': cli.advert,
|
|
'description': 'Send single advertisement (recommended)',
|
|
},
|
|
'floodadv': {
|
|
'function': cli.floodadv,
|
|
'description': 'Flood advertisement (use sparingly!)',
|
|
},
|
|
'node_discover': {
|
|
'function': cli.node_discover,
|
|
'description': 'Discover nearby mesh nodes (repeaters)',
|
|
},
|
|
}
|
|
|
|
|
|
@api_bp.route('/device/command', methods=['POST'])
|
|
def execute_special_command():
|
|
"""
|
|
Execute a special device command.
|
|
|
|
JSON body:
|
|
command (str): Command name (required) - one of: advert, floodadv, node_discover
|
|
|
|
Returns:
|
|
JSON with command result
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data or 'command' not in data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: command'
|
|
}), 400
|
|
|
|
command = data['command'].strip().lower()
|
|
|
|
if command not in SPECIAL_COMMANDS:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Unknown command: {command}. Available commands: {", ".join(SPECIAL_COMMANDS.keys())}'
|
|
}), 400
|
|
|
|
# Execute the command
|
|
cmd_info = SPECIAL_COMMANDS[command]
|
|
result = cmd_info['function']()
|
|
|
|
# Handle different return types
|
|
if command == 'node_discover':
|
|
# node_discover returns (success, nodes_list)
|
|
success, nodes = result
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'command': command,
|
|
'nodes': nodes,
|
|
'count': len(nodes)
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'command': command,
|
|
'error': 'Failed to discover nodes'
|
|
}), 500
|
|
elif command == 'advert':
|
|
# advert returns (success, message) - parse to show only "Advert sent"
|
|
success, message = result
|
|
if success:
|
|
# Extract clean message - just use "Advert sent" instead of full output
|
|
clean_message = "Advert sent"
|
|
return jsonify({
|
|
'success': True,
|
|
'command': command,
|
|
'message': clean_message
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'command': command,
|
|
'error': message
|
|
}), 500
|
|
else:
|
|
# Other commands (floodadv) return (success, message)
|
|
success, message = result
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'command': command,
|
|
'message': message or f'{command} executed successfully'
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'command': command,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error executing special command: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/device/commands', methods=['GET'])
|
|
def list_special_commands():
|
|
"""
|
|
List available special commands.
|
|
|
|
Returns:
|
|
JSON with list of available commands
|
|
"""
|
|
commands = [
|
|
{'name': name, 'description': info['description']}
|
|
for name, info in SPECIAL_COMMANDS.items()
|
|
]
|
|
return jsonify({
|
|
'success': True,
|
|
'commands': commands
|
|
}), 200
|
|
|
|
|
|
@api_bp.route('/sync', methods=['POST'])
|
|
def sync_messages():
|
|
"""
|
|
Trigger message sync from device.
|
|
|
|
Returns:
|
|
JSON with sync result
|
|
"""
|
|
try:
|
|
success, message = cli.recv_messages()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Messages synced successfully'
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing messages: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/archives', methods=['GET'])
|
|
def get_archives():
|
|
"""
|
|
Get list of available message archives.
|
|
|
|
Returns:
|
|
JSON with list of archives, each with:
|
|
- date (str): Archive date in YYYY-MM-DD format
|
|
- message_count (int): Number of messages in archive
|
|
- file_size (int): Archive file size in bytes
|
|
"""
|
|
try:
|
|
archives = archive_manager.list_archives()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'archives': archives,
|
|
'count': len(archives)
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing archives: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/archive/trigger', methods=['POST'])
|
|
def trigger_archive():
|
|
"""
|
|
Manually trigger message archiving.
|
|
|
|
JSON body:
|
|
date (str): Date to archive in YYYY-MM-DD format (optional, defaults to yesterday)
|
|
|
|
Returns:
|
|
JSON with archive operation result
|
|
"""
|
|
try:
|
|
data = request.get_json() or {}
|
|
archive_date = data.get('date')
|
|
|
|
# Validate date format if provided
|
|
if archive_date:
|
|
try:
|
|
datetime.strptime(archive_date, '%Y-%m-%d')
|
|
except ValueError:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Invalid date format: {archive_date}. Expected YYYY-MM-DD'
|
|
}), 400
|
|
|
|
# Trigger archiving
|
|
result = archive_manager.archive_messages(archive_date)
|
|
|
|
if result['success']:
|
|
return jsonify(result), 200
|
|
else:
|
|
return jsonify(result), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error triggering archive: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/channels', methods=['GET'])
|
|
def get_channels():
|
|
"""
|
|
Get list of configured channels (cached for 30s).
|
|
|
|
Returns:
|
|
JSON with channels list
|
|
"""
|
|
try:
|
|
success, channels = get_channels_cached()
|
|
|
|
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:
|
|
invalidate_channels_cache() # Clear cache to force refresh
|
|
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 (optional for channels starting with #)
|
|
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:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: name'
|
|
}), 400
|
|
|
|
name = data['name'].strip()
|
|
key = data.get('key', '').strip().lower() if 'key' in data else None
|
|
|
|
# Validate: key is optional for channels starting with #
|
|
if not name.startswith('#') and not key:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Key is required for channels not starting with #'
|
|
}), 400
|
|
|
|
# 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 = get_channels_cached()
|
|
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:
|
|
invalidate_channels_cache() # Clear cache to force refresh
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Joined channel "{name}" at slot {index}',
|
|
'channel': {
|
|
'index': index,
|
|
'name': name,
|
|
'key': key if key else 'auto-generated'
|
|
}
|
|
}), 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 and delete all its messages.
|
|
|
|
Args:
|
|
index: Channel index to remove
|
|
|
|
Returns:
|
|
JSON with result
|
|
"""
|
|
try:
|
|
# First, delete all messages for this channel
|
|
messages_deleted = parser.delete_channel_messages(index)
|
|
if not messages_deleted:
|
|
logger.warning(f"Failed to delete messages for channel {index}, continuing with channel removal")
|
|
|
|
# Then remove the channel itself
|
|
success, message = cli.remove_channel(index)
|
|
|
|
if success:
|
|
invalidate_channels_cache() # Clear cache to force refresh
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Channel {index} removed and messages deleted'
|
|
}), 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
|
|
|
|
|
|
@api_bp.route('/messages/updates', methods=['GET'])
|
|
def get_messages_updates():
|
|
"""
|
|
Check for new messages across all channels without fetching full message content.
|
|
Used for intelligent refresh mechanism and unread notifications.
|
|
|
|
Query parameters:
|
|
last_seen (str): JSON object with last seen timestamps per channel
|
|
Format: {"0": 1234567890, "1": 1234567891, ...}
|
|
|
|
Returns:
|
|
JSON with update information per channel:
|
|
{
|
|
"success": true,
|
|
"channels": [
|
|
{
|
|
"index": 0,
|
|
"name": "Public",
|
|
"has_updates": true,
|
|
"latest_timestamp": 1234567900,
|
|
"unread_count": 5
|
|
},
|
|
...
|
|
],
|
|
"total_unread": 10
|
|
}
|
|
"""
|
|
try:
|
|
# Parse last_seen timestamps from query param
|
|
last_seen_str = request.args.get('last_seen', '{}')
|
|
try:
|
|
last_seen = json.loads(last_seen_str)
|
|
# Convert keys to integers and values to floats
|
|
last_seen = {int(k): float(v) for k, v in last_seen.items()}
|
|
except (json.JSONDecodeError, ValueError):
|
|
last_seen = {}
|
|
|
|
# Get list of channels (cached)
|
|
success_ch, channels = get_channels_cached()
|
|
if not success_ch:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to get channels'
|
|
}), 500
|
|
|
|
updates = []
|
|
total_unread = 0
|
|
|
|
# Check each channel for new messages
|
|
for channel in channels:
|
|
channel_idx = channel['index']
|
|
|
|
# Get latest message for this channel
|
|
messages = parser.read_messages(
|
|
limit=1,
|
|
channel_idx=channel_idx,
|
|
days=7 # Only check recent messages
|
|
)
|
|
|
|
latest_timestamp = 0
|
|
if messages and len(messages) > 0:
|
|
latest_timestamp = messages[0]['timestamp']
|
|
|
|
# Check if there are updates
|
|
last_seen_ts = last_seen.get(channel_idx, 0)
|
|
has_updates = latest_timestamp > last_seen_ts
|
|
|
|
# Count unread messages (messages newer than last_seen)
|
|
unread_count = 0
|
|
if has_updates:
|
|
all_messages = parser.read_messages(
|
|
limit=500,
|
|
channel_idx=channel_idx,
|
|
days=7
|
|
)
|
|
unread_count = sum(1 for msg in all_messages if msg['timestamp'] > last_seen_ts)
|
|
total_unread += unread_count
|
|
|
|
updates.append({
|
|
'index': channel_idx,
|
|
'name': channel['name'],
|
|
'has_updates': has_updates,
|
|
'latest_timestamp': latest_timestamp,
|
|
'unread_count': unread_count
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'channels': updates,
|
|
'total_unread': total_unread
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking message updates: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Direct Messages (DM) Endpoints
|
|
# =============================================================================
|
|
|
|
@api_bp.route('/dm/conversations', methods=['GET'])
|
|
def get_dm_conversations():
|
|
"""
|
|
Get list of DM conversations.
|
|
|
|
Query params:
|
|
days (int): Filter to last N days (default: 7)
|
|
|
|
Returns:
|
|
JSON with conversations list:
|
|
{
|
|
"success": true,
|
|
"conversations": [
|
|
{
|
|
"conversation_id": "pk_4563b1621b58",
|
|
"display_name": "daniel5120",
|
|
"pubkey_prefix": "4563b1621b58",
|
|
"last_message_timestamp": 1766491173,
|
|
"last_message_preview": "Hello there...",
|
|
"unread_count": 0,
|
|
"message_count": 15
|
|
}
|
|
],
|
|
"count": 5
|
|
}
|
|
"""
|
|
try:
|
|
days = request.args.get('days', default=7, type=int)
|
|
|
|
conversations = parser.get_dm_conversations(days=days)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'conversations': conversations,
|
|
'count': len(conversations)
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting DM conversations: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/dm/messages', methods=['GET'])
|
|
def get_dm_messages():
|
|
"""
|
|
Get DM messages for a specific conversation.
|
|
|
|
Query params:
|
|
conversation_id (str): Required - conversation identifier (pk_xxx or name_xxx)
|
|
limit (int): Max messages to return (default: 100)
|
|
days (int): Filter to last N days (default: 7)
|
|
|
|
Returns:
|
|
JSON with messages list:
|
|
{
|
|
"success": true,
|
|
"conversation_id": "pk_4563b1621b58",
|
|
"display_name": "daniel5120",
|
|
"messages": [...],
|
|
"count": 25
|
|
}
|
|
"""
|
|
try:
|
|
conversation_id = request.args.get('conversation_id', type=str)
|
|
if not conversation_id:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required parameter: conversation_id'
|
|
}), 400
|
|
|
|
limit = request.args.get('limit', default=100, type=int)
|
|
days = request.args.get('days', default=7, type=int)
|
|
|
|
messages, pubkey_to_name = parser.read_dm_messages(
|
|
limit=limit,
|
|
conversation_id=conversation_id,
|
|
days=days
|
|
)
|
|
|
|
# Determine display name from conversation_id or messages
|
|
display_name = 'Unknown'
|
|
if conversation_id.startswith('pk_'):
|
|
pk = conversation_id[3:]
|
|
display_name = pubkey_to_name.get(pk, pk[:8] + '...')
|
|
elif conversation_id.startswith('name_'):
|
|
display_name = conversation_id[5:]
|
|
|
|
# Also check messages for better name
|
|
for msg in messages:
|
|
if msg['direction'] == 'incoming' and msg.get('sender'):
|
|
display_name = msg['sender']
|
|
break
|
|
elif msg['direction'] == 'outgoing' and msg.get('recipient'):
|
|
display_name = msg['recipient']
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'conversation_id': conversation_id,
|
|
'display_name': display_name,
|
|
'messages': messages,
|
|
'count': len(messages)
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting DM messages: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/dm/messages', methods=['POST'])
|
|
def send_dm_message():
|
|
"""
|
|
Send a direct message.
|
|
|
|
JSON body:
|
|
recipient (str): Contact name (required)
|
|
text (str): Message content (required)
|
|
|
|
Returns:
|
|
JSON with send result:
|
|
{
|
|
"success": true,
|
|
"message": "DM sent",
|
|
"recipient": "daniel5120",
|
|
"status": "pending"
|
|
}
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing JSON body'
|
|
}), 400
|
|
|
|
recipient = data.get('recipient', '').strip()
|
|
text = data.get('text', '').strip()
|
|
|
|
if not recipient:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: recipient'
|
|
}), 400
|
|
|
|
if not text:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: text'
|
|
}), 400
|
|
|
|
# MeshCore message length limit
|
|
byte_length = len(text.encode('utf-8'))
|
|
if byte_length > 200:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Message too long ({byte_length} bytes). Maximum 200 bytes allowed.'
|
|
}), 400
|
|
|
|
# Send via CLI
|
|
success, message = cli.send_dm(recipient, text)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'DM sent',
|
|
'recipient': recipient,
|
|
'status': 'pending'
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error sending DM: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/dm/updates', methods=['GET'])
|
|
def get_dm_updates():
|
|
"""
|
|
Check for new DMs across all conversations.
|
|
Used for notification badge updates.
|
|
|
|
Query params:
|
|
last_seen (str): JSON object with last seen timestamps per conversation
|
|
Format: {"pk_xxx": 1234567890, "name_yyy": 1234567891, ...}
|
|
|
|
Returns:
|
|
JSON with update information:
|
|
{
|
|
"success": true,
|
|
"total_unread": 5,
|
|
"conversations": [
|
|
{
|
|
"conversation_id": "pk_4563b1621b58",
|
|
"display_name": "daniel5120",
|
|
"unread_count": 3,
|
|
"latest_timestamp": 1766491173
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
try:
|
|
# Parse last_seen timestamps
|
|
last_seen_str = request.args.get('last_seen', '{}')
|
|
try:
|
|
last_seen = json.loads(last_seen_str)
|
|
except json.JSONDecodeError:
|
|
last_seen = {}
|
|
|
|
# Get all conversations
|
|
conversations = parser.get_dm_conversations(days=7)
|
|
|
|
updates = []
|
|
total_unread = 0
|
|
|
|
for conv in conversations:
|
|
conv_id = conv['conversation_id']
|
|
last_seen_ts = last_seen.get(conv_id, 0)
|
|
|
|
# Count unread
|
|
if conv['last_message_timestamp'] > last_seen_ts:
|
|
# Need to count actual unread messages
|
|
messages, _ = parser.read_dm_messages(
|
|
conversation_id=conv_id,
|
|
days=7
|
|
)
|
|
unread_count = sum(1 for m in messages if m['timestamp'] > last_seen_ts)
|
|
else:
|
|
unread_count = 0
|
|
|
|
total_unread += unread_count
|
|
|
|
if unread_count > 0:
|
|
updates.append({
|
|
'conversation_id': conv_id,
|
|
'display_name': conv['display_name'],
|
|
'unread_count': unread_count,
|
|
'latest_timestamp': conv['last_message_timestamp']
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'total_unread': total_unread,
|
|
'conversations': updates
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking DM updates: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Contact Management (Existing, Pending Contacts & Settings)
|
|
# =============================================================================
|
|
|
|
@api_bp.route('/contacts/detailed', methods=['GET'])
|
|
def get_contacts_detailed_api():
|
|
"""
|
|
Get detailed list of ALL existing contacts on the device (CLI, REP, ROOM, SENS).
|
|
|
|
Returns full contact_info data from meshcli including GPS coordinates, paths, etc.
|
|
|
|
Returns:
|
|
JSON with contacts list:
|
|
{
|
|
"success": true,
|
|
"count": 263,
|
|
"limit": 350,
|
|
"contacts": [
|
|
{
|
|
"name": "TK Zalesie Test 🦜", // adv_name
|
|
"public_key": "df2027d3f2ef...", // Full public key (64 chars)
|
|
"public_key_prefix": "df2027d3f2ef", // First 12 chars
|
|
"type": 2, // 1=CLI, 2=REP, 3=ROOM, 4=SENS
|
|
"type_label": "REP", // Human-readable type
|
|
"flags": 0,
|
|
"out_path_len": -1, // -1 = Flood mode
|
|
"out_path": "", // Path string
|
|
"last_advert": 1735429453, // Unix timestamp
|
|
"adv_lat": 50.866005, // GPS latitude
|
|
"adv_lon": 20.669308, // GPS longitude
|
|
"lastmod": 1715973527 // Last modification timestamp
|
|
},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
try:
|
|
# Get detailed contact info from meshcli (includes all fields)
|
|
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
|
|
|
|
if not success_detailed:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': error_detailed or 'Failed to get contact details',
|
|
'contacts': [],
|
|
'count': 0,
|
|
'limit': 350
|
|
}), 500
|
|
|
|
# Convert dict to list and add computed fields
|
|
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
|
|
contacts = []
|
|
|
|
for public_key, details in contacts_detailed.items():
|
|
# Compute path display string
|
|
out_path_len = details.get('out_path_len', -1)
|
|
out_path = details.get('out_path', '')
|
|
if out_path_len == -1:
|
|
path_or_mode = 'Flood'
|
|
elif out_path:
|
|
path_or_mode = out_path
|
|
else:
|
|
path_or_mode = f'Path len: {out_path_len}'
|
|
|
|
contact = {
|
|
# All original fields from contact_info
|
|
'public_key': public_key,
|
|
'type': details.get('type'),
|
|
'flags': details.get('flags'),
|
|
'out_path_len': out_path_len,
|
|
'out_path': out_path,
|
|
'last_advert': details.get('last_advert'),
|
|
'adv_lat': details.get('adv_lat'),
|
|
'adv_lon': details.get('adv_lon'),
|
|
'lastmod': details.get('lastmod'),
|
|
# Computed/convenience fields
|
|
'name': details.get('adv_name', ''), # Map adv_name to name for compatibility
|
|
'public_key_prefix': public_key[:12] if len(public_key) >= 12 else public_key,
|
|
'type_label': type_labels.get(details.get('type'), 'UNKNOWN'),
|
|
'path_or_mode': path_or_mode, # For UI display
|
|
'last_seen': details.get('last_advert'), # Alias for compatibility
|
|
}
|
|
contacts.append(contact)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'contacts': contacts,
|
|
'count': len(contacts),
|
|
'limit': 350 # MeshCore device limit
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting detailed contacts list: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'contacts': [],
|
|
'count': 0,
|
|
'limit': 350
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/contacts/delete', methods=['POST'])
|
|
def delete_contact_api():
|
|
"""
|
|
Delete a contact from the device.
|
|
|
|
JSON body:
|
|
{
|
|
"selector": "<public_key_prefix_or_name>"
|
|
}
|
|
|
|
Using public_key_prefix is recommended for reliability.
|
|
|
|
Returns:
|
|
JSON with deletion result:
|
|
{
|
|
"success": true,
|
|
"message": "Contact removed successfully"
|
|
}
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data or 'selector' not in data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: selector'
|
|
}), 400
|
|
|
|
selector = data['selector']
|
|
|
|
if not isinstance(selector, str) or not selector.strip():
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'selector must be a non-empty string'
|
|
}), 400
|
|
|
|
success, message = cli.delete_contact(selector)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deleting contact: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Contact Management (Pending Contacts & Settings)
|
|
# =============================================================================
|
|
|
|
@api_bp.route('/contacts/pending', methods=['GET'])
|
|
def get_pending_contacts_api():
|
|
"""
|
|
Get list of contacts awaiting manual approval.
|
|
|
|
Returns:
|
|
JSON with pending contacts list:
|
|
{
|
|
"success": true,
|
|
"pending": [
|
|
{
|
|
"name": "Skyllancer",
|
|
"public_key": "f9ef123abc..."
|
|
},
|
|
...
|
|
],
|
|
"count": 2
|
|
}
|
|
"""
|
|
try:
|
|
success, pending, error = cli.get_pending_contacts()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'pending': pending,
|
|
'count': len(pending)
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': error or 'Failed to get pending contacts',
|
|
'pending': []
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting pending contacts: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'pending': []
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/contacts/pending/approve', methods=['POST'])
|
|
def approve_pending_contact_api():
|
|
"""
|
|
Approve and add a pending contact.
|
|
|
|
JSON body:
|
|
{
|
|
"public_key": "<full_public_key>"
|
|
}
|
|
|
|
IMPORTANT: Always send the full public_key (not name or prefix).
|
|
Full public key works for all contact types (CLI, ROOM, REP, SENS).
|
|
|
|
Returns:
|
|
JSON with approval result:
|
|
{
|
|
"success": true,
|
|
"message": "Contact approved successfully"
|
|
}
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data or 'public_key' not in data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: public_key'
|
|
}), 400
|
|
|
|
public_key = data['public_key']
|
|
|
|
if not isinstance(public_key, str) or not public_key.strip():
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'public_key must be a non-empty string'
|
|
}), 400
|
|
|
|
success, message = cli.approve_pending_contact(public_key)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message or 'Contact approved successfully'
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error approving pending contact: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/device/settings', methods=['GET'])
|
|
def get_device_settings_api():
|
|
"""
|
|
Get persistent device settings.
|
|
|
|
Returns:
|
|
JSON with settings:
|
|
{
|
|
"success": true,
|
|
"settings": {
|
|
"manual_add_contacts": false
|
|
}
|
|
}
|
|
"""
|
|
try:
|
|
success, settings = cli.get_device_settings()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'settings': settings
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to get device settings',
|
|
'settings': {'manual_add_contacts': False}
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting device settings: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'settings': {'manual_add_contacts': False}
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/device/settings', methods=['POST'])
|
|
def update_device_settings_api():
|
|
"""
|
|
Update persistent device settings.
|
|
|
|
JSON body:
|
|
{
|
|
"manual_add_contacts": true/false
|
|
}
|
|
|
|
This setting is:
|
|
1. Saved to .webui_settings.json for persistence across container restarts
|
|
2. Applied immediately to the running meshcli session
|
|
|
|
Returns:
|
|
JSON with update result:
|
|
{
|
|
"success": true,
|
|
"message": "manual_add_contacts set to on"
|
|
}
|
|
"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
if not data or 'manual_add_contacts' not in data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: manual_add_contacts'
|
|
}), 400
|
|
|
|
manual_add_contacts = data['manual_add_contacts']
|
|
|
|
if not isinstance(manual_add_contacts, bool):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'manual_add_contacts must be a boolean'
|
|
}), 400
|
|
|
|
success, message = cli.set_manual_add_contacts(manual_add_contacts)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message,
|
|
'settings': {'manual_add_contacts': manual_add_contacts}
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': message
|
|
}), 500
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating device settings: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
|
|
# =============================================================================
|
|
# Read Status (Server-side message read tracking)
|
|
# =============================================================================
|
|
|
|
@api_bp.route('/read_status', methods=['GET'])
|
|
def get_read_status_api():
|
|
"""
|
|
Get server-side read status for all channels and DM conversations.
|
|
|
|
This replaces localStorage-based tracking to enable cross-device synchronization.
|
|
|
|
Returns:
|
|
JSON with read status:
|
|
{
|
|
"success": true,
|
|
"channels": {
|
|
"0": 1735900000,
|
|
"1": 1735900100
|
|
},
|
|
"dm": {
|
|
"name_User1": 1735900200,
|
|
"pk_abc123": 1735900300
|
|
}
|
|
}
|
|
"""
|
|
try:
|
|
from app import read_status
|
|
|
|
status = read_status.load_read_status()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'channels': status['channels'],
|
|
'dm': status['dm']
|
|
}), 200
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting read status: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'channels': {},
|
|
'dm': {}
|
|
}), 500
|
|
|
|
|
|
@api_bp.route('/read_status/mark_read', methods=['POST'])
|
|
def mark_read_api():
|
|
"""
|
|
Mark a channel or DM conversation as read.
|
|
|
|
JSON body (one of the following):
|
|
{"type": "channel", "channel_idx": 0, "timestamp": 1735900000}
|
|
{"type": "dm", "conversation_id": "name_User1", "timestamp": 1735900200}
|
|
|
|
Returns:
|
|
JSON with result:
|
|
{
|
|
"success": true,
|
|
"message": "Channel marked as read"
|
|
}
|
|
"""
|
|
try:
|
|
from app import read_status
|
|
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing JSON body'
|
|
}), 400
|
|
|
|
msg_type = data.get('type')
|
|
timestamp = data.get('timestamp')
|
|
|
|
if not msg_type or not timestamp:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required fields: type and timestamp'
|
|
}), 400
|
|
|
|
if msg_type == 'channel':
|
|
channel_idx = data.get('channel_idx')
|
|
if channel_idx is None:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: channel_idx'
|
|
}), 400
|
|
|
|
success = read_status.mark_channel_read(channel_idx, timestamp)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Channel {channel_idx} marked as read'
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to save read status'
|
|
}), 500
|
|
|
|
elif msg_type == 'dm':
|
|
conversation_id = data.get('conversation_id')
|
|
if not conversation_id:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Missing required field: conversation_id'
|
|
}), 400
|
|
|
|
success = read_status.mark_dm_read(conversation_id, timestamp)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'DM conversation {conversation_id} marked as read'
|
|
}), 200
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to save read status'
|
|
}), 500
|
|
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'Invalid type: {msg_type}. Must be "channel" or "dm"'
|
|
}), 400
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error marking as read: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|