diff --git a/server/pyproject.toml b/server/pyproject.toml index 8438bb1..a26e5b1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "python-dateutil>=2.9.0", "python-multipart>=0.0.12", "wmi>=1.5.1; sys_platform == 'win32'", + "zeroconf>=0.131.0", ] [project.optional-dependencies] diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 338a9bc..68b57a1 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -19,6 +19,8 @@ from wled_controller.api.schemas.devices import ( DeviceResponse, DeviceStateResponse, DeviceUpdate, + DiscoveredDeviceResponse, + DiscoverDevicesResponse, ) from wled_controller.core.calibration import ( calibration_from_dict, @@ -143,6 +145,45 @@ async def list_devices( return DeviceListResponse(devices=responses, count=len(responses)) +@router.get("/api/v1/devices/discover", response_model=DiscoverDevicesResponse, tags=["Devices"]) +async def discover_devices( + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + timeout: float = 3.0, +): + """Scan the local network for WLED devices via mDNS.""" + import time + from wled_controller.core.discovery import discover_wled_devices + + start = time.time() + discovered = await discover_wled_devices(timeout=min(timeout, 10.0)) + elapsed_ms = (time.time() - start) * 1000 + + existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()} + + results = [] + for d in discovered: + already_added = d.url.rstrip("/").lower() in existing_urls + results.append( + DiscoveredDeviceResponse( + name=d.name, + url=d.url, + device_type=d.device_type, + ip=d.ip, + mac=d.mac, + led_count=d.led_count, + version=d.version, + already_added=already_added, + ) + ) + + return DiscoverDevicesResponse( + devices=results, + count=len(results), + scan_duration_ms=round(elapsed_ms, 1), + ) + + @router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) async def get_device( device_id: str, diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index e58adc5..59c55ae 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -113,3 +113,24 @@ class DeviceStateResponse(BaseModel): device_error: Optional[str] = Field(None, description="Last health check error") test_mode: bool = Field(default=False, description="Whether calibration test mode is active") test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode") + + +class DiscoveredDeviceResponse(BaseModel): + """A single device found via network discovery.""" + + name: str = Field(description="Device name (from mDNS or firmware)") + url: str = Field(description="Device URL") + device_type: str = Field(default="wled", description="Device type") + ip: str = Field(description="IP address") + mac: str = Field(default="", description="MAC address") + led_count: Optional[int] = Field(None, description="LED count (if reachable)") + version: Optional[str] = Field(None, description="Firmware version") + already_added: bool = Field(default=False, description="Whether this device is already in the system") + + +class DiscoverDevicesResponse(BaseModel): + """Response from device discovery scan.""" + + devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices") + count: int = Field(description="Total devices found") + scan_duration_ms: float = Field(description="How long the scan took in milliseconds") diff --git a/server/src/wled_controller/core/discovery.py b/server/src/wled_controller/core/discovery.py new file mode 100644 index 0000000..df20d4a --- /dev/null +++ b/server/src/wled_controller/core/discovery.py @@ -0,0 +1,113 @@ +"""Network discovery for LED devices (mDNS/DNS-SD).""" + +import asyncio +from dataclasses import dataclass +from typing import List, Optional + +import httpx +from zeroconf import ServiceStateChange, Zeroconf +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf + +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +WLED_MDNS_TYPE = "_wled._tcp.local." +DEFAULT_SCAN_TIMEOUT = 3.0 + + +@dataclass +class DiscoveredDevice: + """A device found via network discovery.""" + + name: str + url: str + device_type: str + ip: str + mac: str + led_count: Optional[int] + version: Optional[str] + + +async def _enrich_wled_device( + url: str, fallback_name: str +) -> tuple[str, Optional[str], Optional[int], str]: + """Probe a WLED device's /json/info to get name, version, LED count, MAC.""" + try: + async with httpx.AsyncClient(timeout=2) as client: + resp = await client.get(f"{url}/json/info") + resp.raise_for_status() + data = resp.json() + return ( + data.get("name", fallback_name), + data.get("ver"), + data.get("leds", {}).get("count"), + data.get("mac", ""), + ) + except Exception as e: + logger.debug(f"Could not fetch WLED info from {url}: {e}") + return fallback_name, None, None, "" + + +async def discover_wled_devices( + timeout: float = DEFAULT_SCAN_TIMEOUT, +) -> List[DiscoveredDevice]: + """Scan the local network for WLED devices via mDNS.""" + discovered: dict[str, AsyncServiceInfo] = {} + + def on_state_change(**kwargs): + service_type = kwargs.get("service_type", "") + name = kwargs.get("name", "") + state_change = kwargs.get("state_change") + if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): + discovered[name] = AsyncServiceInfo(service_type, name) + + aiozc = AsyncZeroconf() + browser = AsyncServiceBrowser( + aiozc.zeroconf, WLED_MDNS_TYPE, handlers=[on_state_change] + ) + + await asyncio.sleep(timeout) + + # Resolve all discovered services + for info in discovered.values(): + await info.async_request(aiozc.zeroconf, timeout=2000) + + await browser.async_cancel() + + # Build raw list with IPs, then enrich in parallel + raw: list[tuple[str, str, str]] = [] # (service_name, ip, url) + for name, info in discovered.items(): + addrs = info.parsed_addresses() + if not addrs: + continue + ip = addrs[0] + port = info.port or 80 + url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}" + service_name = name.replace(f".{WLED_MDNS_TYPE}", "") + raw.append((service_name, ip, url)) + + # Enrich all devices in parallel + enrichment = await asyncio.gather( + *[_enrich_wled_device(url, sname) for sname, _, url in raw] + ) + + results: List[DiscoveredDevice] = [] + for (service_name, ip, url), (wled_name, version, led_count, mac) in zip( + raw, enrichment + ): + results.append( + DiscoveredDevice( + name=wled_name, + url=url, + device_type="wled", + ip=ip, + mac=mac, + led_count=led_count, + version=version, + ) + ) + + await aiozc.async_close() + logger.info(f"mDNS scan found {len(results)} WLED device(s)") + return results diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 45e83a0..061dd2a 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -879,6 +879,16 @@ function showAddDevice() { const error = document.getElementById('add-device-error'); form.reset(); error.style.display = 'none'; + // Reset discovery section + const section = document.getElementById('discovery-section'); + if (section) { + section.style.display = 'none'; + document.getElementById('discovery-list').innerHTML = ''; + document.getElementById('discovery-empty').style.display = 'none'; + document.getElementById('discovery-loading').style.display = 'none'; + } + const scanBtn = document.getElementById('scan-network-btn'); + if (scanBtn) scanBtn.disabled = false; modal.style.display = 'flex'; lockBody(); setTimeout(() => document.getElementById('device-name').focus(), 100); @@ -890,6 +900,79 @@ function closeAddDeviceModal() { unlockBody(); } +async function scanForDevices() { + const loading = document.getElementById('discovery-loading'); + const list = document.getElementById('discovery-list'); + const empty = document.getElementById('discovery-empty'); + const section = document.getElementById('discovery-section'); + const scanBtn = document.getElementById('scan-network-btn'); + + section.style.display = 'block'; + loading.style.display = 'flex'; + list.innerHTML = ''; + empty.style.display = 'none'; + if (scanBtn) scanBtn.disabled = true; + + try { + const response = await fetch(`${API_BASE}/devices/discover?timeout=3`, { + headers: getHeaders() + }); + + if (response.status === 401) { handle401Error(); return; } + + loading.style.display = 'none'; + if (scanBtn) scanBtn.disabled = false; + + if (!response.ok) { + empty.style.display = 'block'; + empty.querySelector('small').textContent = t('device.scan.error'); + return; + } + + const data = await response.json(); + + if (data.devices.length === 0) { + empty.style.display = 'block'; + return; + } + + data.devices.forEach(device => { + const card = document.createElement('div'); + card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : ''); + const meta = [device.ip]; + if (device.led_count) meta.push(device.led_count + ' LEDs'); + if (device.version) meta.push('v' + device.version); + card.innerHTML = ` +