""" 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/', 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//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": "" } 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": "" } 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