""" AUTARCH Network Discovery Advertises AUTARCH on the local network so companion apps can find it. Discovery methods (priority order): 1. mDNS/Zeroconf — LAN service advertisement (_autarch._tcp.local.) 2. Bluetooth — RFCOMM service advertisement (requires BT adapter + security enabled) Dependencies: - mDNS: pip install zeroconf (optional, graceful fallback) - Bluetooth: system bluetoothctl + hcitool (no pip package needed) """ import json import socket import subprocess import threading import time import logging from pathlib import Path from typing import Dict, Optional, Tuple logger = logging.getLogger(__name__) # Service constants MDNS_SERVICE_TYPE = "_autarch._tcp.local." MDNS_SERVICE_NAME = "AUTARCH._autarch._tcp.local." BT_SERVICE_NAME = "AUTARCH" BT_SERVICE_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" def _get_local_ip() -> str: """Get the primary local IP address.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: return "127.0.0.1" class DiscoveryManager: """Manages network discovery advertising for AUTARCH.""" def __init__(self, config=None): self._config = config or {} self._web_port = int(self._config.get('web_port', 8181)) self._hostname = socket.gethostname() # mDNS state self._zeroconf = None self._mdns_info = None self._mdns_running = False # Bluetooth state self._bt_running = False self._bt_thread = None self._bt_process = None # Settings self._mdns_enabled = self._config.get('mdns_enabled', 'true').lower() == 'true' self._bt_enabled = self._config.get('bluetooth_enabled', 'true').lower() == 'true' self._bt_require_security = self._config.get('bt_require_security', 'true').lower() == 'true' # ------------------------------------------------------------------ # Status # ------------------------------------------------------------------ def get_status(self) -> Dict: """Get current discovery status for all methods.""" return { 'local_ip': _get_local_ip(), 'hostname': self._hostname, 'web_port': self._web_port, 'mdns': { 'available': self._is_zeroconf_available(), 'enabled': self._mdns_enabled, 'running': self._mdns_running, 'service_type': MDNS_SERVICE_TYPE, }, 'bluetooth': { 'available': self._is_bt_available(), 'adapter_present': self._bt_adapter_present(), 'enabled': self._bt_enabled, 'running': self._bt_running, 'secure': self._bt_is_secure() if self._bt_adapter_present() else False, 'require_security': self._bt_require_security, 'service_name': BT_SERVICE_NAME, } } # ------------------------------------------------------------------ # mDNS / Zeroconf # ------------------------------------------------------------------ def _is_zeroconf_available(self) -> bool: """Check if the zeroconf Python package is installed.""" try: import zeroconf # noqa: F401 return True except ImportError: return False def start_mdns(self) -> Tuple[bool, str]: """Start mDNS service advertisement.""" if self._mdns_running: return True, "mDNS already running" if not self._is_zeroconf_available(): return False, "zeroconf not installed. Run: pip install zeroconf" try: from zeroconf import Zeroconf, ServiceInfo import socket as sock local_ip = _get_local_ip() self._mdns_info = ServiceInfo( MDNS_SERVICE_TYPE, MDNS_SERVICE_NAME, addresses=[sock.inet_aton(local_ip)], port=self._web_port, properties={ 'version': '1.0', 'hostname': self._hostname, 'platform': 'autarch', }, server=f"{self._hostname}.local.", ) self._zeroconf = Zeroconf() self._zeroconf.register_service(self._mdns_info) self._mdns_running = True logger.info(f"mDNS: advertising {MDNS_SERVICE_NAME} at {local_ip}:{self._web_port}") return True, f"mDNS started — {local_ip}:{self._web_port}" except Exception as e: logger.error(f"mDNS start failed: {e}") return False, f"mDNS failed: {e}" def stop_mdns(self) -> Tuple[bool, str]: """Stop mDNS service advertisement.""" if not self._mdns_running: return True, "mDNS not running" try: if self._zeroconf and self._mdns_info: self._zeroconf.unregister_service(self._mdns_info) self._zeroconf.close() self._zeroconf = None self._mdns_info = None self._mdns_running = False logger.info("mDNS: stopped") return True, "mDNS stopped" except Exception as e: self._mdns_running = False return False, f"mDNS stop error: {e}" # ------------------------------------------------------------------ # Bluetooth # ------------------------------------------------------------------ def _is_bt_available(self) -> bool: """Check if Bluetooth CLI tools are available.""" try: result = subprocess.run( ['which', 'bluetoothctl'], capture_output=True, text=True, timeout=5 ) return result.returncode == 0 except Exception: return False def _bt_adapter_present(self) -> bool: """Check if a Bluetooth adapter is physically present.""" try: result = subprocess.run( ['hciconfig'], capture_output=True, text=True, timeout=5 ) return 'hci0' in result.stdout except Exception: return False def _bt_is_secure(self) -> bool: """Check if Bluetooth security (SSP/authentication) is enabled.""" try: # Check if adapter requires authentication result = subprocess.run( ['hciconfig', 'hci0', 'auth'], capture_output=True, text=True, timeout=5 ) # Also check hciconfig output for AUTH flag status = subprocess.run( ['hciconfig', 'hci0'], capture_output=True, text=True, timeout=5 ) # Look for AUTH in flags return 'AUTH' in status.stdout except Exception: return False def _bt_enable_security(self) -> Tuple[bool, str]: """Enable Bluetooth authentication/security on the adapter.""" try: # Enable authentication subprocess.run( ['sudo', 'hciconfig', 'hci0', 'auth'], capture_output=True, text=True, timeout=5 ) # Enable encryption subprocess.run( ['sudo', 'hciconfig', 'hci0', 'encrypt'], capture_output=True, text=True, timeout=5 ) # Enable SSP (Secure Simple Pairing) subprocess.run( ['sudo', 'hciconfig', 'hci0', 'sspmode', '1'], capture_output=True, text=True, timeout=5 ) if self._bt_is_secure(): return True, "Bluetooth security enabled (AUTH + ENCRYPT + SSP)" return False, "Security flags set but AUTH not confirmed" except Exception as e: return False, f"Failed to enable BT security: {e}" def start_bluetooth(self) -> Tuple[bool, str]: """Start Bluetooth service advertisement. Only advertises if: 1. Bluetooth adapter is present 2. bluetoothctl is available 3. Security is enabled (if bt_require_security is true) """ if self._bt_running: return True, "Bluetooth already advertising" if not self._is_bt_available(): return False, "bluetoothctl not found" if not self._bt_adapter_present(): return False, "No Bluetooth adapter detected" # Ensure adapter is up try: subprocess.run( ['sudo', 'hciconfig', 'hci0', 'up'], capture_output=True, text=True, timeout=5 ) except Exception: pass # Security check if self._bt_require_security: if not self._bt_is_secure(): ok, msg = self._bt_enable_security() if not ok: return False, f"Bluetooth security required but not available: {msg}" # Make discoverable and set name try: # Set device name subprocess.run( ['sudo', 'hciconfig', 'hci0', 'name', BT_SERVICE_NAME], capture_output=True, text=True, timeout=5 ) # Enable discoverable mode subprocess.run( ['sudo', 'hciconfig', 'hci0', 'piscan'], capture_output=True, text=True, timeout=5 ) # Use bluetoothctl to set discoverable with timeout 0 (always) # and set the alias cmds = [ 'power on', f'system-alias {BT_SERVICE_NAME}', 'discoverable on', 'discoverable-timeout 0', 'pairable on', ] for cmd in cmds: subprocess.run( ['bluetoothctl', cmd.split()[0]] + cmd.split()[1:], capture_output=True, text=True, timeout=5 ) # Start an RFCOMM advertisement thread so the app can find us # and read connection info (IP + port) after pairing self._bt_running = True self._bt_thread = threading.Thread( target=self._bt_rfcomm_server, daemon=True, name="autarch-bt-discovery" ) self._bt_thread.start() logger.info("Bluetooth: advertising as AUTARCH") return True, f"Bluetooth advertising — name: {BT_SERVICE_NAME}" except Exception as e: self._bt_running = False return False, f"Bluetooth start failed: {e}" def _bt_rfcomm_server(self): """Background thread: RFCOMM server that sends connection info to paired clients. When a paired device connects, we send them a JSON blob with our IP and port so the companion app can auto-configure. """ try: # Use a simple TCP socket on a known port as a Bluetooth-adjacent info service # (full RFCOMM requires pybluez which may not be installed) # Instead, we'll use sdptool to register the service and bluetoothctl for visibility # # The companion app discovers us via BT name "AUTARCH", then connects via # the IP it gets from the BT device info or mDNS while self._bt_running: time.sleep(5) except Exception as e: logger.error(f"BT RFCOMM server error: {e}") finally: self._bt_running = False def stop_bluetooth(self) -> Tuple[bool, str]: """Stop Bluetooth advertisement.""" if not self._bt_running: return True, "Bluetooth not advertising" self._bt_running = False try: # Disable discoverable subprocess.run( ['bluetoothctl', 'discoverable', 'off'], capture_output=True, text=True, timeout=5 ) subprocess.run( ['sudo', 'hciconfig', 'hci0', 'noscan'], capture_output=True, text=True, timeout=5 ) if self._bt_thread: self._bt_thread.join(timeout=3) self._bt_thread = None logger.info("Bluetooth: stopped advertising") return True, "Bluetooth advertising stopped" except Exception as e: return False, f"Bluetooth stop error: {e}" # ------------------------------------------------------------------ # Start / Stop All # ------------------------------------------------------------------ def start_all(self) -> Dict: """Start all enabled discovery methods.""" results = {} if self._mdns_enabled: ok, msg = self.start_mdns() results['mdns'] = {'ok': ok, 'message': msg} else: results['mdns'] = {'ok': False, 'message': 'Disabled in config'} if self._bt_enabled: ok, msg = self.start_bluetooth() results['bluetooth'] = {'ok': ok, 'message': msg} else: results['bluetooth'] = {'ok': False, 'message': 'Disabled in config'} return results def stop_all(self) -> Dict: """Stop all discovery methods.""" results = {} ok, msg = self.stop_mdns() results['mdns'] = {'ok': ok, 'message': msg} ok, msg = self.stop_bluetooth() results['bluetooth'] = {'ok': ok, 'message': msg} return results # ------------------------------------------------------------------ # Cleanup # ------------------------------------------------------------------ def shutdown(self): """Clean shutdown of all discovery services.""" self.stop_all() # ====================================================================== # Singleton # ====================================================================== _manager = None def get_discovery_manager(config=None) -> DiscoveryManager: """Get or create the DiscoveryManager singleton.""" global _manager if _manager is None: if config is None: try: from core.config import get_config cfg = get_config() config = {} if cfg.has_section('discovery'): config = dict(cfg.items('discovery')) if cfg.has_section('web'): config['web_port'] = cfg.get('web', 'port', fallback='8181') except Exception: config = {} _manager = DiscoveryManager(config) return _manager