Add WLED auto-discovery via mDNS with zeroconf

Scan the local network for WLED devices advertising _wled._tcp.local.
and present them in the Add Device modal for one-click selection.

- New discovery.py: async mDNS browse + parallel /json/info enrichment
- GET /api/v1/devices/discover endpoint with already_added dedup
- Header scan button (magnifying glass icon) in add-device modal
- Discovered devices show name, IP, LED count, version; click to fill form
- en/ru locale strings for discovery UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 13:06:29 +03:00
parent b5a6885126
commit 638dc526f9
9 changed files with 378 additions and 1 deletions

View File

@@ -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,

View File

@@ -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")