""" mDNS service registration for GSPro Remote. Allows the application to be discovered on the local network. """ import logging import socket import threading from typing import Optional, Dict, Any try: from zeroconf import ServiceInfo, Zeroconf, IPVersion except ImportError as e: raise ImportError(f"Zeroconf library not installed: {e}") logger = logging.getLogger(__name__) class MDNSService: """ Manages mDNS/Bonjour service registration for network discovery. """ def __init__( self, name: str = "gsproapp", port: int = 5005, service_type: str = "_http._tcp.local.", properties: Optional[Dict[str, Any]] = None, ): """ Initialize mDNS service. Args: name: Service name (will be accessible as {name}.local) port: Port number the service is running on service_type: mDNS service type properties: Additional service properties """ self.name = name self.port = port self.service_type = service_type self.properties = properties or {} self.zeroconf: Optional[Zeroconf] = None self.service_info: Optional[ServiceInfo] = None self.is_running = False self._lock = threading.Lock() def _get_local_ip(self) -> str: """ Get the local IP address of the machine. Returns: Local IP address as string """ try: # Create a socket to determine the local IP with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: # Connect to a public DNS server to determine local interface s.connect(("8.8.8.8", 80)) return s.getsockname()[0] except Exception: # Fallback to localhost if can't determine return "127.0.0.1" def _create_service_info(self) -> ServiceInfo: """ Create the ServiceInfo object for registration. Returns: Configured ServiceInfo object """ local_ip = self._get_local_ip() hostname = socket.gethostname() # Create fully qualified service name service_name = f"{self.name}.{self.service_type}" # Add default properties default_properties = { "version": "0.1.0", "platform": "windows", "api": "rest", "ui": "web", } # Merge with custom properties all_properties = {**default_properties, **self.properties} # Convert properties to bytes properties_bytes = {} for key, value in all_properties.items(): if isinstance(value, str): properties_bytes[key] = value.encode("utf-8") elif isinstance(value, bytes): properties_bytes[key] = value else: properties_bytes[key] = str(value).encode("utf-8") # Create service info service_info = ServiceInfo( type_=self.service_type, name=service_name, addresses=[socket.inet_aton(local_ip)], port=self.port, properties=properties_bytes, server=f"{hostname}.local.", ) return service_info def start(self) -> bool: """ Start the mDNS service registration. Returns: True if service started successfully, False otherwise """ with self._lock: if self.is_running: logger.warning("mDNS service is already running") return True try: # Create Zeroconf instance self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only) # Create and register service self.service_info = self._create_service_info() self.zeroconf.register_service(self.service_info) self.is_running = True logger.info(f"mDNS service registered: {self.name}.local:{self.port} (type: {self.service_type})") return True except Exception as e: logger.error(f"Failed to start mDNS service: {e}") self.cleanup() return False def stop(self) -> None: """Stop the mDNS service registration.""" with self._lock: if not self.is_running: return self.cleanup() self.is_running = False logger.info("mDNS service stopped") def cleanup(self) -> None: """Clean up mDNS resources.""" try: if self.zeroconf and self.service_info: self.zeroconf.unregister_service(self.service_info) if self.zeroconf: self.zeroconf.close() self.zeroconf = None self.service_info = None except Exception as e: logger.error(f"Error during mDNS cleanup: {e}") def update_properties(self, properties: Dict[str, Any]) -> bool: """ Update service properties. Args: properties: New properties to set Returns: True if properties updated successfully, False otherwise """ with self._lock: if not self.is_running: logger.warning("Cannot update properties: service is not running") return False try: self.properties.update(properties) # Recreate and re-register service with new properties if self.zeroconf and self.service_info: self.zeroconf.unregister_service(self.service_info) self.service_info = self._create_service_info() self.zeroconf.register_service(self.service_info) logger.info("mDNS service properties updated") return True except Exception as e: logger.error(f"Failed to update mDNS properties: {e}") return False def get_url(self) -> str: """ Get the URL for accessing the service. Returns: Service URL """ return f"http://{self.name}.local:{self.port}" def __enter__(self): """Context manager entry.""" self.start() return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.stop() class MDNSBrowser: """ Browse for mDNS services on the network. Useful for discovering other GSPro Remote instances. """ def __init__(self, service_type: str = "_http._tcp.local."): """ Initialize mDNS browser. Args: service_type: Type of services to browse for """ self.service_type = service_type self.services: Dict[str, Dict[str, Any]] = {} self.zeroconf: Optional[Zeroconf] = None def browse(self, timeout: float = 5.0) -> Dict[str, Dict[str, Any]]: """ Browse for services on the network. Args: timeout: Time to wait for services (seconds) Returns: Dictionary of discovered services """ try: from zeroconf import ServiceBrowser, ServiceListener import time class Listener(ServiceListener): def __init__(self, browser): self.browser = browser def add_service(self, zeroconf, service_type, name): info = zeroconf.get_service_info(service_type, name) if info: self.browser.services[name] = { "name": name, "address": socket.inet_ntoa(info.addresses[0]) if info.addresses else None, "port": info.port, "properties": info.properties, } def remove_service(self, zeroconf, service_type, name): self.browser.services.pop(name, None) def update_service(self, zeroconf, service_type, name): pass self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only) listener = Listener(self) browser = ServiceBrowser(self.zeroconf, self.service_type, listener) # Wait for services to be discovered time.sleep(timeout) browser.cancel() self.zeroconf.close() return self.services except Exception as e: logger.error(f"Failed to browse for services: {e}") return {} # Test function for development def test_mdns_service(): """Test mDNS service registration.""" import time print("Testing mDNS service registration...") # Test service registration service = MDNSService( name="gsproapp-test", port=5005, properties={"test": "true", "instance": "development"}, ) if service.start(): print(f"✓ mDNS service started: {service.get_url()}") print(f" You should be able to access it at: http://gsproapp-test.local:5005") # Keep service running for 10 seconds print(" Service will run for 10 seconds...") time.sleep(10) # Test property update if service.update_properties({"status": "running", "uptime": "10s"}): print("✓ Properties updated successfully") service.stop() print("✓ mDNS service stopped") else: print("✗ Failed to start mDNS service") # Test service browsing print("\nBrowsing for HTTP services on the network...") browser = MDNSBrowser() services = browser.browse(timeout=3.0) if services: print(f"Found {len(services)} services:") for name, info in services.items(): print(f" - {name}: {info['address']}:{info['port']}") else: print("No services found") print("\nmDNS test complete!") if __name__ == "__main__": test_mdns_service()