feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
End-to-end BLE streaming: provider + client + per-protocol wire encoders with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge via Chaquopy) transports, discovery with protocol-family detection that auto-fills the UI, throttled not-connected warning + 10 s reconnect cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame, and an explicit asyncio.wait_for wrapper around bleak connect() since the WinRT backend doesn't always honor the timeout kwarg. Also rewrites server/restart.ps1 to be parameterized (-Port / -Module / -PythonVersion / timeouts / -Quiet), pick the right interpreter via the py launcher, pre-flight the target module, poll port readiness on both shutdown and startup, redirect child stdout/stderr so Start-Process doesn't hang on inherited Git-Bash handles, and return proper exit codes. Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh resources, Chaquopy-safe value_stream psutil fallback, setup-required modal, asset-store test coverage, and misc system/config touch-ups.
This commit is contained in:
@@ -8,10 +8,10 @@ inside an Android application. Sets up Android-specific paths
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
_server_thread: Optional[threading.Thread] = None
|
||||
_shutdown_event: Optional[asyncio.Event] = None
|
||||
_server: Optional[Any] = None # uvicorn.Server
|
||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
|
||||
@@ -63,22 +63,27 @@ def start_server(data_dir: str, port: int = 8080) -> None:
|
||||
# No uvloop/httptools on Android — use pure-Python asyncio
|
||||
loop="asyncio",
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
global _shutdown_event, _loop
|
||||
global _server, _loop
|
||||
_server = uvicorn.Server(uv_config)
|
||||
_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_loop)
|
||||
_shutdown_event = asyncio.Event()
|
||||
|
||||
logger.info("LedGrab Android: server starting")
|
||||
_loop.run_until_complete(server.serve())
|
||||
_loop.run_until_complete(_server.serve())
|
||||
logger.info("LedGrab Android: server stopped")
|
||||
|
||||
# Clean up so the next start_server() call begins fresh.
|
||||
_server = None
|
||||
_loop = None
|
||||
|
||||
|
||||
def stop_server() -> None:
|
||||
"""Signal the uvicorn server to shut down gracefully.
|
||||
|
||||
Called from Kotlin's ``PythonBridge.stopServer()``.
|
||||
Called from Kotlin's ``PythonBridge.stopServer()``. Sets
|
||||
``should_exit`` on the uvicorn Server which causes ``server.serve()``
|
||||
to return, unblocking the Python thread.
|
||||
"""
|
||||
if _shutdown_event is not None and _loop is not None:
|
||||
_loop.call_soon_threadsafe(_shutdown_event.set)
|
||||
if _server is not None:
|
||||
_server.should_exit = True
|
||||
|
||||
@@ -66,6 +66,8 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
gamesense_device_type=device.gamesense_device_type,
|
||||
ble_family=device.ble_family,
|
||||
ble_govee_key=device.ble_govee_key,
|
||||
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||
group_device_ids=device.group_device_ids,
|
||||
group_mode=device.group_mode,
|
||||
@@ -198,6 +200,8 @@ async def create_device(
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
||||
ble_family=device_data.ble_family or "",
|
||||
ble_govee_key=device_data.ble_govee_key or "",
|
||||
group_device_ids=group_device_ids,
|
||||
group_mode=group_mode,
|
||||
)
|
||||
@@ -281,6 +285,7 @@ async def discover_devices(
|
||||
mac=d.mac,
|
||||
led_count=d.led_count,
|
||||
version=d.version,
|
||||
ble_family=getattr(d, "ble_family", None),
|
||||
already_added=already_added,
|
||||
)
|
||||
)
|
||||
@@ -430,6 +435,8 @@ async def update_device(
|
||||
spi_led_type=update_data.spi_led_type,
|
||||
chroma_device_type=update_data.chroma_device_type,
|
||||
gamesense_device_type=update_data.gamesense_device_type,
|
||||
ble_family=update_data.ble_family,
|
||||
ble_govee_key=update_data.ble_govee_key,
|
||||
group_device_ids=update_data.group_device_ids,
|
||||
group_mode=update_data.group_mode,
|
||||
)
|
||||
|
||||
@@ -12,10 +12,10 @@ from typing import Optional
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from ledgrab import __version__, REPO_URL, DONATE_URL
|
||||
from ledgrab.api.auth import AuthRequired, is_auth_enabled
|
||||
from ledgrab.api.auth import AuthRequired, _is_loopback, is_auth_enabled
|
||||
from ledgrab.api.dependencies import (
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
@@ -96,19 +96,30 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, tags=["Health"])
|
||||
async def health_check():
|
||||
async def health_check(request: Request):
|
||||
"""Check service health status.
|
||||
|
||||
Returns basic health information including status, version, and timestamp.
|
||||
"""
|
||||
logger.debug("Health check requested")
|
||||
|
||||
client_host = request.client.host if request.client else None
|
||||
loopback = _is_loopback(client_host)
|
||||
keys_configured = is_auth_enabled()
|
||||
# Report auth_required=True for LAN clients even when no keys are configured,
|
||||
# because the server rejects non-loopback requests without keys.
|
||||
auth_required = keys_configured or not loopback
|
||||
# LAN client with no keys configured → no key will ever work; signal to
|
||||
# the UI so it can show a setup-required screen instead of a login form.
|
||||
setup_required = not keys_configured and not loopback
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
version=__version__,
|
||||
demo_mode=get_config().demo,
|
||||
auth_required=is_auth_enabled(),
|
||||
auth_required=auth_required,
|
||||
setup_required=setup_required,
|
||||
repo_url=REPO_URL,
|
||||
donate_url=DONATE_URL,
|
||||
)
|
||||
|
||||
@@ -66,6 +66,15 @@ class DeviceCreate(BaseModel):
|
||||
gamesense_device_type: Optional[str] = Field(
|
||||
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
|
||||
)
|
||||
# BLE controller fields
|
||||
ble_family: Optional[str] = Field(
|
||||
None,
|
||||
description="BLE protocol family: sp110e, triones, zengge, govee",
|
||||
)
|
||||
ble_govee_key: Optional[str] = Field(
|
||||
None,
|
||||
description="Govee AES key (hex) — required for encrypted Govee firmware",
|
||||
)
|
||||
default_css_processing_template_id: Optional[str] = Field(
|
||||
None, description="Default color strip processing template ID"
|
||||
)
|
||||
@@ -117,6 +126,12 @@ class DeviceUpdate(BaseModel):
|
||||
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
|
||||
ble_family: Optional[str] = Field(
|
||||
None, description="BLE protocol family: sp110e, triones, zengge, govee"
|
||||
)
|
||||
ble_govee_key: Optional[str] = Field(
|
||||
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
|
||||
)
|
||||
default_css_processing_template_id: Optional[str] = Field(
|
||||
None, description="Default color strip processing template ID"
|
||||
)
|
||||
@@ -266,6 +281,12 @@ class DeviceResponse(BaseModel):
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
|
||||
ble_family: str = Field(
|
||||
default="", description="BLE protocol family: sp110e, triones, zengge, govee"
|
||||
)
|
||||
ble_govee_key: str = Field(
|
||||
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
|
||||
)
|
||||
default_css_processing_template_id: str = Field(
|
||||
default="", description="Default color strip processing template ID"
|
||||
)
|
||||
@@ -320,6 +341,9 @@ class DiscoveredDeviceResponse(BaseModel):
|
||||
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")
|
||||
ble_family: Optional[str] = Field(
|
||||
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
|
||||
)
|
||||
already_added: bool = Field(
|
||||
default=False, description="Whether this device is already in the system"
|
||||
)
|
||||
|
||||
@@ -16,6 +16,14 @@ class HealthResponse(BaseModel):
|
||||
auth_required: bool = Field(
|
||||
default=True, description="Whether API key authentication is required"
|
||||
)
|
||||
setup_required: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True when the server has no API keys configured AND the request "
|
||||
"comes from a non-loopback client. The client is effectively locked "
|
||||
"out until someone configures auth.api_keys server-side."
|
||||
),
|
||||
)
|
||||
repo_url: str = Field(default="", description="Source code repository URL")
|
||||
donate_url: str = Field(default="", description="Donation page URL")
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ import yaml
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from ledgrab import paths as _paths
|
||||
|
||||
# Evaluate once at import time so every StorageConfig/AssetsConfig instance
|
||||
# sees the same default across the process. Use POSIX separators so the
|
||||
# default value is stable across platforms (SQLite and Python both accept
|
||||
# forward slashes on Windows).
|
||||
_DEFAULT_DATA_DIR_STR = _paths.default_data_dir().as_posix()
|
||||
|
||||
# ── Legacy env var migration ─────────────────────────────────
|
||||
# Warn users who still have WLED_ env vars from pre-rename installs.
|
||||
_OLD_PREFIX = "WLED_"
|
||||
@@ -66,13 +74,13 @@ class AssetsConfig(BaseSettings):
|
||||
"""Assets configuration."""
|
||||
|
||||
max_file_size_mb: int = 50 # Max upload size in MB
|
||||
assets_dir: str = "data/assets" # Directory for uploaded asset files
|
||||
assets_dir: str = f"{_DEFAULT_DATA_DIR_STR}/assets"
|
||||
|
||||
|
||||
class StorageConfig(BaseSettings):
|
||||
"""Storage configuration."""
|
||||
|
||||
database_file: str = "data/ledgrab.db"
|
||||
database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db"
|
||||
|
||||
|
||||
class MQTTConfig(BaseSettings):
|
||||
@@ -163,16 +171,29 @@ class Config(BaseSettings):
|
||||
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
"""Override storage and assets paths when demo mode is active."""
|
||||
if self.demo:
|
||||
for field_name in StorageConfig.model_fields:
|
||||
value = getattr(self.storage, field_name)
|
||||
if isinstance(value, str) and value.startswith("data/"):
|
||||
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
|
||||
for field_name in AssetsConfig.model_fields:
|
||||
value = getattr(self.assets, field_name)
|
||||
if isinstance(value, str) and value.startswith("data/"):
|
||||
setattr(self.assets, field_name, value.replace("data/", "data/demo/", 1))
|
||||
"""Override storage and assets paths when demo mode is active.
|
||||
|
||||
Inserts a ``demo`` segment before the final path component so that
|
||||
``<data_dir>/ledgrab.db`` becomes ``<data_dir>/demo/ledgrab.db``.
|
||||
Works for both absolute platform paths and legacy relative ones.
|
||||
"""
|
||||
if not self.demo:
|
||||
return
|
||||
|
||||
def _demo_path(value: str) -> str:
|
||||
p = Path(value)
|
||||
if "demo" in p.parts:
|
||||
return value
|
||||
return str(p.parent / "demo" / p.name)
|
||||
|
||||
for field_name in StorageConfig.model_fields:
|
||||
value = getattr(self.storage, field_name)
|
||||
if isinstance(value, str) and value:
|
||||
setattr(self.storage, field_name, _demo_path(value))
|
||||
for field_name in AssetsConfig.model_fields:
|
||||
value = getattr(self.assets, field_name)
|
||||
if isinstance(value, str) and value:
|
||||
setattr(self.assets, field_name, _demo_path(value))
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, config_path: str | Path) -> "Config":
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Android BLE transport backed by the Kotlin ``BleBridge`` singleton.
|
||||
|
||||
Calls into Java land through Chaquopy. This module only loads on Android;
|
||||
importing it on desktop raises ``RuntimeError`` from ``_bridge()``.
|
||||
|
||||
The public surface mirrors :class:`~ledgrab.core.devices.ble_transport.BLETransport`
|
||||
so ``BLEClient`` can treat both backends identically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.platform import is_android
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _bridge():
|
||||
"""Return the Kotlin ``BleBridge`` singleton, or raise on non-Android."""
|
||||
if not is_android():
|
||||
raise RuntimeError("AndroidBLETransport is only usable on Android")
|
||||
try:
|
||||
from java import jclass # type: ignore[import-not-found]
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Chaquopy java interop not available") from exc
|
||||
return jclass("com.ledgrab.android.BleBridge").INSTANCE
|
||||
|
||||
|
||||
async def android_ble_scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
|
||||
"""Scan for BLE peripherals using the Android ``BleBridge``.
|
||||
|
||||
Runs the blocking scan on a thread-pool thread so the asyncio event
|
||||
loop is not blocked during the scan window.
|
||||
"""
|
||||
bridge = _bridge()
|
||||
timeout_ms = int(timeout * 1000)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _scan() -> List[DiscoveredBLEDevice]:
|
||||
results = bridge.scan(timeout_ms)
|
||||
devices: List[DiscoveredBLEDevice] = []
|
||||
for entry in results:
|
||||
parts = str(entry).split("|", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
address, name, rssi_str = parts
|
||||
try:
|
||||
rssi: Optional[int] = int(rssi_str)
|
||||
except ValueError:
|
||||
rssi = None
|
||||
devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi))
|
||||
return devices
|
||||
|
||||
devices = await loop.run_in_executor(None, _scan)
|
||||
devices.sort(key=lambda d: (d.rssi is None, -(d.rssi or 0)))
|
||||
return devices
|
||||
|
||||
|
||||
class AndroidBLETransport:
|
||||
"""BLE transport for Android — delegates to the Kotlin ``BleBridge`` singleton.
|
||||
|
||||
Lifecycle is identical to :class:`~ledgrab.core.devices.ble_transport.BLETransport`:
|
||||
transport = AndroidBLETransport(address, write_char_uuid, ...)
|
||||
await transport.connect()
|
||||
await transport.write(b"...")
|
||||
await transport.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: str,
|
||||
write_char_uuid: str,
|
||||
write_with_response: bool = False,
|
||||
connect_timeout: float = 10.0,
|
||||
) -> None:
|
||||
self._address = address
|
||||
self._write_char_uuid = write_char_uuid
|
||||
self._write_with_response = write_with_response
|
||||
self._handle: Optional[int] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._handle is not None and self._handle >= 0
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.is_connected:
|
||||
return
|
||||
bridge = _bridge()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
handle = await loop.run_in_executor(
|
||||
None, lambda: int(bridge.connect(self._address, self._write_char_uuid))
|
||||
)
|
||||
if handle < 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to connect to BLE device {self._address} via Android bridge "
|
||||
f"(device not found, permission denied, or characteristic missing)"
|
||||
)
|
||||
self._handle = handle
|
||||
logger.info("Android BLE connected: address=%s handle=%d", self._address, handle)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._handle is None:
|
||||
return
|
||||
bridge = _bridge()
|
||||
handle = self._handle
|
||||
self._handle = None
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, lambda: bridge.disconnect(handle))
|
||||
except Exception as exc:
|
||||
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
"""Write bytes to the configured characteristic.
|
||||
|
||||
Serialised through an internal lock — BLE stacks do not tolerate
|
||||
overlapping writes on the same characteristic.
|
||||
"""
|
||||
if not self.is_connected or self._handle is None:
|
||||
raise RuntimeError(f"Android BLE transport {self._address} not connected")
|
||||
bridge = _bridge()
|
||||
handle = self._handle
|
||||
with_response = self._write_with_response
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async with self._lock:
|
||||
success = await loop.run_in_executor(
|
||||
None, lambda: bool(bridge.write(handle, data, with_response))
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError(f"Android BLE write to {self._address} failed")
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Unified BLE LED client — whole-strip ambient color for BLE controllers.
|
||||
|
||||
Supports four families via the :mod:`ble_protocols` registry: SP110E,
|
||||
Triones/HappyLighting, Zengge/iLightsIn, Govee. None of these protocols
|
||||
stream per-pixel frames — so ``send_pixels`` averages the incoming strip
|
||||
and writes one solid color per frame.
|
||||
|
||||
URL format: ``ble://<address>`` where ``<address>`` is a MAC on
|
||||
Windows/Linux and a CoreBluetooth UUID on macOS. The protocol family is
|
||||
a separate ``ble_family`` field on the device record (not in the URL)
|
||||
because the same address advertises different services depending on
|
||||
firmware variant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol, get_protocol
|
||||
from ledgrab.core.devices.ble_transport import make_transport
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Minimum interval between BLE writes. BLE connection intervals start at
|
||||
# ~7.5 ms on most controllers; 30 ms leaves headroom for GATT ACKs on the
|
||||
# with-response families without saturating the air time.
|
||||
_MIN_WRITE_INTERVAL_SEC = 0.03
|
||||
|
||||
|
||||
def _encrypt_govee_frame(frame: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB encrypt a 20-byte Govee frame using a 16-byte device key.
|
||||
|
||||
Newer Govee firmware (2022+) drops unencrypted frames silently.
|
||||
Pads the 20-byte frame to 32 bytes (two AES blocks) before encrypting.
|
||||
Falls back to the plaintext frame if the ``cryptography`` package is
|
||||
unavailable (logs a warning so the user knows why the controller ignores it).
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"cryptography package not available — sending unencrypted Govee frame; "
|
||||
"install it with: pip install cryptography"
|
||||
)
|
||||
return frame
|
||||
padded = frame + b"\x00" * (32 - len(frame))
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
enc = cipher.encryptor()
|
||||
return enc.update(padded) + enc.finalize()
|
||||
|
||||
|
||||
def _strip_ble_scheme(url: str) -> str:
|
||||
"""Normalise a ``ble://<address>`` URL to just the address."""
|
||||
if url.startswith("ble://"):
|
||||
return url[len("ble://") :].strip("/")
|
||||
return url.strip("/")
|
||||
|
||||
|
||||
def _average_color(pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
|
||||
mean = arr.mean(axis=0)
|
||||
return int(mean[0]), int(mean[1]), int(mean[2])
|
||||
if not pixels:
|
||||
return (0, 0, 0)
|
||||
total_r = total_g = total_b = 0
|
||||
for r, g, b in pixels:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
|
||||
|
||||
class BLEClient(LEDClient):
|
||||
"""LED client for BLE controllers speaking one of the registered protocols.
|
||||
|
||||
Args:
|
||||
url: ``ble://<address>`` URL.
|
||||
ble_family: Family identifier (``sp110e``, ``triones``, ``zengge``, ``govee``).
|
||||
led_count: Logical LED count — recorded for UI/reporting; on the wire
|
||||
every BLE protocol here is whole-strip.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
ble_family: str,
|
||||
led_count: int = 0,
|
||||
ble_govee_key: str = "",
|
||||
**_kwargs,
|
||||
):
|
||||
self._url = url
|
||||
self._address = _strip_ble_scheme(url)
|
||||
self._led_count = led_count
|
||||
self._protocol: BLEProtocol = get_protocol(ble_family)
|
||||
self._transport = make_transport(
|
||||
address=self._address,
|
||||
write_char_uuid=self._protocol.write_char_uuid,
|
||||
write_with_response=self._protocol.write_with_response,
|
||||
)
|
||||
# AES key for Govee encrypted firmware — 16 raw bytes or None.
|
||||
self._aes_key: Optional[bytes] = None
|
||||
if ble_govee_key and ble_family == "govee":
|
||||
try:
|
||||
import binascii
|
||||
|
||||
key_bytes = binascii.unhexlify(ble_govee_key.strip())
|
||||
if len(key_bytes) != 16:
|
||||
raise ValueError(f"Govee AES key must be 16 bytes, got {len(key_bytes)}")
|
||||
self._aes_key = key_bytes
|
||||
except Exception as exc:
|
||||
logger.warning("Invalid Govee AES key — ignoring: %s", exc)
|
||||
self._last_write_at: float = 0.0
|
||||
self._last_color: Optional[Tuple[int, int, int, int]] = None
|
||||
self._connected = False
|
||||
# Throttle "not connected" warnings so the send loop doesn't spam logs
|
||||
# at frame rate when a BLE connection drops silently.
|
||||
self._last_not_connected_warn_at: float = 0.0
|
||||
# When a reconnect attempt fails, skip further write attempts for a
|
||||
# cooldown window. Each failed write on Windows can hang up to the
|
||||
# transport's write timeout + a full connect timeout, so letting
|
||||
# every frame retry turns a 60 FPS loop into a 0.03 FPS slideshow.
|
||||
self._reconnect_cooldown_until: float = 0.0
|
||||
|
||||
async def connect(self) -> bool:
|
||||
await self._transport.connect()
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"BLE client connected: address=%s family=%s", self._address, self._protocol.family
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
# Leave the strip in whatever state it's in — streaming power commands
|
||||
# on every connect/close cycle causes Windows BLE stack quirks (back-to-back
|
||||
# writes after connect can hang for 30s on some firmwares). The user can
|
||||
# explicitly toggle power via the UI.
|
||||
await self._transport.close()
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._transport.is_connected
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the strip to one color and write it — BLE protocols are whole-strip only."""
|
||||
now = time.monotonic()
|
||||
if now < self._reconnect_cooldown_until:
|
||||
return False
|
||||
if not self.is_connected:
|
||||
if (now - self._last_not_connected_warn_at) >= 5.0:
|
||||
logger.warning(
|
||||
"BLE send_pixels skipped — not connected (address=%s family=%s)",
|
||||
self._address,
|
||||
self._protocol.family,
|
||||
)
|
||||
self._last_not_connected_warn_at = now
|
||||
return False
|
||||
|
||||
r, g, b = _average_color(pixels)
|
||||
color = (r, g, b, brightness)
|
||||
|
||||
# Skip exact duplicates within a short window — long idle periods on a
|
||||
# BLE connection can cause the peripheral to drop it, after which the
|
||||
# next write hangs for 30s on Windows. A 250ms window forces regular
|
||||
# traffic which keeps cheap BLE LED chips alive without flooding them.
|
||||
now = time.monotonic()
|
||||
if color == self._last_color and (now - self._last_write_at) < 0.25:
|
||||
return True
|
||||
delay = _MIN_WRITE_INTERVAL_SEC - (now - self._last_write_at)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
frame = self._protocol.encode_color(r, g, b, brightness)
|
||||
if self._aes_key is not None:
|
||||
frame = _encrypt_govee_frame(frame, self._aes_key)
|
||||
try:
|
||||
await self._transport.write(frame)
|
||||
except asyncio.TimeoutError:
|
||||
# BLE connection likely dropped silently — reconnect and retry once.
|
||||
logger.warning(
|
||||
"BLE write to %s (%s) timed out — reconnecting",
|
||||
self._address,
|
||||
self._protocol.family,
|
||||
)
|
||||
try:
|
||||
await self._transport.close()
|
||||
await self._transport.connect()
|
||||
await self._transport.write(frame)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"BLE reconnect+retry to %s failed — backing off 10s: %s",
|
||||
self._address,
|
||||
exc,
|
||||
)
|
||||
self._reconnect_cooldown_until = time.monotonic() + 10.0
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"BLE write to %s (%s) failed: %s", self._address, self._protocol.family, exc
|
||||
)
|
||||
return False
|
||||
|
||||
self._last_color = color
|
||||
self._last_write_at = time.monotonic()
|
||||
return True
|
||||
|
||||
async def set_power(self, on: bool) -> bool:
|
||||
if not self.is_connected:
|
||||
return False
|
||||
try:
|
||||
frame = self._protocol.encode_power(on)
|
||||
if self._aes_key is not None:
|
||||
frame = _encrypt_govee_frame(frame, self._aes_key)
|
||||
await self._transport.write(frame)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("BLE power command to %s failed: %s", self._address, exc)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client, # noqa: ARG003 — unused; kept for the LEDClient contract
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""BLE health isn't a passive check — a full GATT connect is the only signal.
|
||||
|
||||
Doing that on every poll would exhaust the controller's connection
|
||||
slots, so we report the previously observed state and refresh only
|
||||
the timestamp. Live errors surface via ``send_pixels`` and are
|
||||
persisted by the device health tracker.
|
||||
"""
|
||||
address = _strip_ble_scheme(url)
|
||||
return DeviceHealth(
|
||||
online=prev_health.online if prev_health else False,
|
||||
latency_ms=prev_health.latency_ms if prev_health else None,
|
||||
last_checked=datetime.now(timezone.utc),
|
||||
device_name=prev_health.device_name if prev_health else address,
|
||||
device_version=prev_health.device_version if prev_health else None,
|
||||
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||
device_led_type=prev_health.device_led_type if prev_health else None,
|
||||
error=prev_health.error if prev_health else None,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""BLE LED controller protocols.
|
||||
|
||||
Each submodule implements one controller family's wire protocol as a set
|
||||
of pure byte-encoding functions. The :class:`BLEProtocol` contract defines
|
||||
the minimal surface every family must expose; :func:`get_protocol` looks
|
||||
one up by family identifier.
|
||||
|
||||
Protocols live here as pure functions (no BLE dependency) so they can be
|
||||
unit-tested without hardware and without the ``bleak`` package installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BLEProtocol:
|
||||
"""Wire protocol for one BLE LED controller family.
|
||||
|
||||
Attributes:
|
||||
family: Short identifier (``sp110e``, ``triones``, ``zengge``, ``govee``).
|
||||
display_name: Human-readable name for UIs.
|
||||
service_uuid: GATT service UUID containing the write characteristic.
|
||||
write_char_uuid: Write characteristic UUID.
|
||||
write_with_response: If True, use Write Request; else Write Without Response.
|
||||
encode_color: ``(r, g, b, brightness) -> bytes`` — frame setting a solid color.
|
||||
encode_power: ``on -> bytes`` — frame toggling power.
|
||||
name_prefixes: Advertisement-name prefixes that identify this family.
|
||||
"""
|
||||
|
||||
family: str
|
||||
display_name: str
|
||||
service_uuid: str
|
||||
write_char_uuid: str
|
||||
write_with_response: bool
|
||||
encode_color: Callable[[int, int, int, int], bytes]
|
||||
encode_power: Callable[[bool], bytes]
|
||||
name_prefixes: Tuple[str, ...]
|
||||
|
||||
|
||||
_registry: Dict[str, BLEProtocol] = {}
|
||||
|
||||
|
||||
def register_protocol(protocol: BLEProtocol) -> None:
|
||||
"""Register a protocol so :func:`get_protocol` can find it."""
|
||||
_registry[protocol.family] = protocol
|
||||
|
||||
|
||||
def get_protocol(family: str) -> BLEProtocol:
|
||||
"""Look up a registered protocol by family identifier."""
|
||||
try:
|
||||
return _registry[family]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown BLE family: {family!r}") from exc
|
||||
|
||||
|
||||
def all_protocols() -> Dict[str, BLEProtocol]:
|
||||
"""Return a copy of the registry (family → protocol)."""
|
||||
return dict(_registry)
|
||||
|
||||
|
||||
def identify_family(advertised_name: str) -> str | None:
|
||||
"""Best-effort family detection from a BLE advertisement name.
|
||||
|
||||
Returns the family identifier if the name matches a known prefix,
|
||||
otherwise ``None``.
|
||||
"""
|
||||
if not advertised_name:
|
||||
return None
|
||||
for proto in _registry.values():
|
||||
for prefix in proto.name_prefixes:
|
||||
if advertised_name.startswith(prefix):
|
||||
return proto.family
|
||||
return None
|
||||
|
||||
|
||||
def identify_family_by_service_uuids(service_uuids: Tuple[str, ...]) -> str | None:
|
||||
"""Best-effort family detection from advertised GATT service UUIDs.
|
||||
|
||||
Returns the first matching family or ``None``. Families that share the
|
||||
same service UUID (e.g. SP110E and Zengge both use FFE0) are matched in
|
||||
registration order — SP110E is registered first so it wins the tie.
|
||||
"""
|
||||
if not service_uuids:
|
||||
return None
|
||||
uuids_lower = {u.lower() for u in service_uuids}
|
||||
for proto in _registry.values():
|
||||
if proto.service_uuid.lower() in uuids_lower:
|
||||
return proto.family
|
||||
return None
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
# Imported lazily to avoid circular imports during module init.
|
||||
from ledgrab.core.devices.ble_protocols import govee, sp110e, triones, zengge
|
||||
|
||||
register_protocol(sp110e.PROTOCOL)
|
||||
register_protocol(triones.PROTOCOL)
|
||||
register_protocol(zengge.PROTOCOL)
|
||||
register_protocol(govee.PROTOCOL)
|
||||
|
||||
|
||||
_register_builtins()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Govee BLE controller protocol (experimental, per-model AES keyed).
|
||||
|
||||
Govee H6XXX strips speak a 20-byte framed BLE protocol. Newer firmware
|
||||
(2022+) additionally wraps every frame with AES-128 where the key is
|
||||
derived per model. Without the correct key the controller silently
|
||||
drops frames.
|
||||
|
||||
This module exposes the **unencrypted** frame encoder — enough to drive
|
||||
older Govee firmware and useful as a scaffold if a community AES key
|
||||
ends up being wired in later. The encoder is pure; key negotiation
|
||||
belongs in the transport layer where it can cache per-address state.
|
||||
|
||||
Frame layout (20 bytes):
|
||||
|
||||
``33 05 02 RR GG BB 00 00 00 00 00 00 00 00 00 00 00 00 00 XX``
|
||||
|
||||
where ``XX`` is an XOR checksum of bytes 0..18.
|
||||
|
||||
Reference:
|
||||
* https://github.com/Freemanium/govee_btled (reverse-engineered)
|
||||
* https://github.com/Beshelmek/govee_ble_lights
|
||||
|
||||
Status: **experimental**. If frames are silently dropped the model likely
|
||||
requires encryption — that is out of scope here and will raise a clear
|
||||
error at the transport layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol
|
||||
|
||||
# Govee uses a single 128-bit custom service with one write characteristic.
|
||||
_SERVICE_UUID = "00010203-0405-0607-0809-0a0b0c0d1910"
|
||||
_WRITE_CHAR_UUID = "00010203-0405-0607-0809-0a0b0c0d2b11"
|
||||
|
||||
_CMD_COLOR = 0x05
|
||||
_CMD_POWER = 0x01
|
||||
_MODE_MANUAL = 0x02
|
||||
|
||||
|
||||
def _clamp_byte(value: int) -> int:
|
||||
if value < 0:
|
||||
return 0
|
||||
if value > 255:
|
||||
return 255
|
||||
return value
|
||||
|
||||
|
||||
def _frame(command: int, payload: bytes) -> bytes:
|
||||
"""Wrap a command + payload in Govee's 20-byte framed format with XOR checksum."""
|
||||
if len(payload) > 17:
|
||||
raise ValueError("Govee payload must be ≤17 bytes")
|
||||
buf = bytearray(20)
|
||||
buf[0] = 0x33
|
||||
buf[1] = command & 0xFF
|
||||
buf[2 : 2 + len(payload)] = payload
|
||||
checksum = 0
|
||||
for i in range(19):
|
||||
checksum ^= buf[i]
|
||||
buf[19] = checksum & 0xFF
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
|
||||
"""Build a Govee "set solid color" frame."""
|
||||
r = _clamp_byte(r)
|
||||
g = _clamp_byte(g)
|
||||
b = _clamp_byte(b)
|
||||
brightness = _clamp_byte(brightness)
|
||||
if brightness != 255:
|
||||
r = (r * brightness) // 255
|
||||
g = (g * brightness) // 255
|
||||
b = (b * brightness) // 255
|
||||
return _frame(_CMD_COLOR, bytes((_MODE_MANUAL, r, g, b)))
|
||||
|
||||
|
||||
def encode_power(on: bool) -> bytes:
|
||||
"""Build a Govee power on/off frame."""
|
||||
return _frame(_CMD_POWER, bytes((0x01 if on else 0x00,)))
|
||||
|
||||
|
||||
PROTOCOL = BLEProtocol(
|
||||
family="govee",
|
||||
display_name="Govee H6XXX (unencrypted — experimental)",
|
||||
service_uuid=_SERVICE_UUID,
|
||||
write_char_uuid=_WRITE_CHAR_UUID,
|
||||
# Govee requires Write Request (with response) for reliable delivery.
|
||||
write_with_response=True,
|
||||
encode_color=encode_color,
|
||||
encode_power=encode_power,
|
||||
name_prefixes=("ihoment_H6", "Govee_H6", "Minger_H6"),
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""SP110E / SP108E addressable-BLE-controller protocol.
|
||||
|
||||
The SP110E is a BLE controller for addressable LED strips (WS2811, WS2812B,
|
||||
SK6812, APA102, etc.). Its phone app (several rebrands, including "LED Hue",
|
||||
"SP110E", "Custom Lights") streams control commands — but does **not** stream
|
||||
per-pixel frames. The BLE protocol exposes:
|
||||
|
||||
* pick LED IC type + channel order
|
||||
* pick a built-in animation pattern
|
||||
* set animation speed + brightness
|
||||
* set a single static color for the whole strip
|
||||
|
||||
So from LedGrab's perspective, SP110E is a whole-strip ambient controller.
|
||||
|
||||
Frame format (5 bytes, big-endian):
|
||||
|
||||
``RR GG BB 00 CC``
|
||||
|
||||
where ``CC`` is the command byte. Static-color command is ``0x1E`` (set
|
||||
"RGB" mode = whole-strip solid color from the RR GG BB payload). Power is
|
||||
a distinct command (``0xAA`` ON / ``0xAB`` OFF, with the three payload
|
||||
bytes ignored). Brightness is applied by the *caller* scaling the RGB
|
||||
triple — there is no separate brightness command for solid-color mode,
|
||||
which is simpler and lets LedGrab apply its own processing pipeline.
|
||||
|
||||
References:
|
||||
* https://github.com/Lehkeda/SP110E_controller (reverse-engineered)
|
||||
* https://github.com/sysofwan/ha-sp110e
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol
|
||||
|
||||
_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
|
||||
_WRITE_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
_CMD_SET_COLOR = 0x1E
|
||||
_CMD_POWER_ON = 0xAA
|
||||
_CMD_POWER_OFF = 0xAB
|
||||
|
||||
|
||||
def _clamp_byte(value: int) -> int:
|
||||
if value < 0:
|
||||
return 0
|
||||
if value > 255:
|
||||
return 255
|
||||
return value
|
||||
|
||||
|
||||
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
|
||||
"""Build a "set solid color" frame.
|
||||
|
||||
Applies software brightness by scaling RGB — the SP110E protocol has
|
||||
no separate brightness channel for static color mode.
|
||||
"""
|
||||
r = _clamp_byte(r)
|
||||
g = _clamp_byte(g)
|
||||
b = _clamp_byte(b)
|
||||
brightness = _clamp_byte(brightness)
|
||||
if brightness != 255:
|
||||
r = (r * brightness) // 255
|
||||
g = (g * brightness) // 255
|
||||
b = (b * brightness) // 255
|
||||
return bytes((r, g, b, 0x00, _CMD_SET_COLOR))
|
||||
|
||||
|
||||
def encode_power(on: bool) -> bytes:
|
||||
"""Build a power on/off frame."""
|
||||
cmd = _CMD_POWER_ON if on else _CMD_POWER_OFF
|
||||
return bytes((0x00, 0x00, 0x00, 0x00, cmd))
|
||||
|
||||
|
||||
PROTOCOL = BLEProtocol(
|
||||
family="sp110e",
|
||||
display_name="SP110E / SP108E (addressable)",
|
||||
service_uuid=_SERVICE_UUID,
|
||||
write_char_uuid=_WRITE_CHAR_UUID,
|
||||
# SP110E accepts Write Without Response — much lower latency.
|
||||
write_with_response=False,
|
||||
encode_color=encode_color,
|
||||
encode_power=encode_power,
|
||||
name_prefixes=("SP110E", "SP108E", "BLE-LED"),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Triones / HappyLighting / LEDnet BLE controller protocol.
|
||||
|
||||
Applies to the large family of cheap single-color BLE RGB(W) controllers
|
||||
sold under names like ``Triones``, ``LEDnet``, ``HappyLighting``,
|
||||
``Magic Home BLE``. They share a 9-byte framed protocol:
|
||||
|
||||
``7E 07 05 03 RR GG BB 10 EF`` — set solid color (RGB)
|
||||
``7E 04 04 RR GG BB 10 EF`` — some LEDnet variants use the shorter form
|
||||
|
||||
Power is a separate frame:
|
||||
|
||||
``7E 04 04 F0 00 01 FF 00 EF`` — ON
|
||||
``7E 04 04 00 00 00 FF 00 EF`` — OFF
|
||||
|
||||
Reference implementations:
|
||||
* https://github.com/sysofwan/ha-magicfan
|
||||
* https://github.com/madhead/saberlight
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol
|
||||
|
||||
_SERVICE_UUID = "0000ffe5-0000-1000-8000-00805f9b34fb"
|
||||
_WRITE_CHAR_UUID = "0000ffe9-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def _clamp_byte(value: int) -> int:
|
||||
if value < 0:
|
||||
return 0
|
||||
if value > 255:
|
||||
return 255
|
||||
return value
|
||||
|
||||
|
||||
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
|
||||
"""Build a Triones "set solid color" frame.
|
||||
|
||||
Software brightness is applied to RGB — the on-wire protocol has no
|
||||
separate brightness byte.
|
||||
"""
|
||||
r = _clamp_byte(r)
|
||||
g = _clamp_byte(g)
|
||||
b = _clamp_byte(b)
|
||||
brightness = _clamp_byte(brightness)
|
||||
if brightness != 255:
|
||||
r = (r * brightness) // 255
|
||||
g = (g * brightness) // 255
|
||||
b = (b * brightness) // 255
|
||||
return bytes((0x7E, 0x07, 0x05, 0x03, r, g, b, 0x10, 0xEF))
|
||||
|
||||
|
||||
def encode_power(on: bool) -> bytes:
|
||||
"""Build a Triones power on/off frame."""
|
||||
if on:
|
||||
return bytes((0x7E, 0x04, 0x04, 0xF0, 0x00, 0x01, 0xFF, 0x00, 0xEF))
|
||||
return bytes((0x7E, 0x04, 0x04, 0x00, 0x00, 0x00, 0xFF, 0x00, 0xEF))
|
||||
|
||||
|
||||
PROTOCOL = BLEProtocol(
|
||||
family="triones",
|
||||
display_name="Triones / HappyLighting / LEDnet",
|
||||
service_uuid=_SERVICE_UUID,
|
||||
write_char_uuid=_WRITE_CHAR_UUID,
|
||||
write_with_response=False,
|
||||
encode_color=encode_color,
|
||||
encode_power=encode_power,
|
||||
name_prefixes=("Triones", "LEDBLE", "LEDnet", "HappyLighting", "MagicHome"),
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Zengge / iLightsIn BLE controller protocol.
|
||||
|
||||
Zengge (a.k.a. iLightsIn, Mohuan Lighting, generic "LED BLE") controllers
|
||||
use a distinct wire protocol from Triones despite targeting the same
|
||||
segment. Colors are framed with a leading ``0x56`` and trailed with
|
||||
``0xAA``:
|
||||
|
||||
``56 RR GG BB 00 F0 AA`` — set RGB (bright=0xF0 marker)
|
||||
``56 00 00 00 WW 0F AA`` — set warm-white (when RGBW is wired)
|
||||
|
||||
Power is a separate 7-byte frame:
|
||||
|
||||
``CC 23 33`` — ON
|
||||
``CC 24 33`` — OFF
|
||||
|
||||
References:
|
||||
* https://github.com/mjg59/python-zengge
|
||||
* https://github.com/madhead/saberlight
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ledgrab.core.devices.ble_protocols import BLEProtocol
|
||||
|
||||
# Zengge controllers share the FFE0/FFE1 pair with SP110E but run a different
|
||||
# command protocol — a recurring source of confusion. Differentiate them by
|
||||
# advertisement name (see ``name_prefixes``) or by user-picked family.
|
||||
_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
|
||||
_WRITE_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def _clamp_byte(value: int) -> int:
|
||||
if value < 0:
|
||||
return 0
|
||||
if value > 255:
|
||||
return 255
|
||||
return value
|
||||
|
||||
|
||||
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
|
||||
"""Build a Zengge "set RGB" frame."""
|
||||
r = _clamp_byte(r)
|
||||
g = _clamp_byte(g)
|
||||
b = _clamp_byte(b)
|
||||
brightness = _clamp_byte(brightness)
|
||||
if brightness != 255:
|
||||
r = (r * brightness) // 255
|
||||
g = (g * brightness) // 255
|
||||
b = (b * brightness) // 255
|
||||
return bytes((0x56, r, g, b, 0x00, 0xF0, 0xAA))
|
||||
|
||||
|
||||
def encode_power(on: bool) -> bytes:
|
||||
"""Build a Zengge power on/off frame."""
|
||||
return bytes((0xCC, 0x23 if on else 0x24, 0x33))
|
||||
|
||||
|
||||
PROTOCOL = BLEProtocol(
|
||||
family="zengge",
|
||||
display_name="Zengge / iLightsIn",
|
||||
service_uuid=_SERVICE_UUID,
|
||||
write_char_uuid=_WRITE_CHAR_UUID,
|
||||
write_with_response=False,
|
||||
encode_color=encode_color,
|
||||
encode_power=encode_power,
|
||||
name_prefixes=("Zengge", "iLightsIn", "Mohuan"),
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""BLE device provider — dispatch for BLE LED controllers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
|
||||
from ledgrab.core.devices.ble_client import BLEClient, _strip_ble_scheme
|
||||
from ledgrab.core.devices.ble_protocols import (
|
||||
all_protocols,
|
||||
identify_family,
|
||||
identify_family_by_service_uuids,
|
||||
)
|
||||
from ledgrab.core.devices.ble_transport import scan as ble_scan
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import BLEConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BLEDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for BLE LED controllers (SP110E / Triones / Zengge / Govee).
|
||||
|
||||
URL format: ``ble://<address>``. The controller family is stored on
|
||||
the device record as ``ble_family`` — not in the URL — because the
|
||||
same MAC can advertise under different protocol variants depending
|
||||
on firmware.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "ble"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"static_color",
|
||||
# BLE cannot do per-pixel streaming — no fast_send / brightness_control.
|
||||
}
|
||||
|
||||
def create_client(self, config: "BLEConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
if not config.ble_family:
|
||||
raise ValueError(
|
||||
"BLE device requires 'ble_family' (one of: "
|
||||
+ ", ".join(sorted(all_protocols()))
|
||||
+ ")"
|
||||
)
|
||||
return BLEClient(
|
||||
url=config.device_url,
|
||||
ble_family=config.ble_family,
|
||||
led_count=config.led_count,
|
||||
ble_govee_key=config.ble_govee_key,
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await BLEClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""BLE has no cheap, non-intrusive probe — a GATT connect is the only real signal
|
||||
and would contend with an active streaming session, so we only sanity-check shape.
|
||||
|
||||
The live connection will surface a clear error on first ``send_pixels``
|
||||
if the address is wrong or the protocol family was mis-picked.
|
||||
"""
|
||||
address = _strip_ble_scheme(url)
|
||||
if not address:
|
||||
raise ValueError("BLE device URL must be 'ble://<address>'")
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 4.0) -> List[DiscoveredDevice]:
|
||||
"""Scan for BLE peripherals and classify by advertised name prefix."""
|
||||
try:
|
||||
found = await ble_scan(timeout=timeout)
|
||||
except RuntimeError as exc:
|
||||
logger.warning("BLE discovery unavailable: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for device in found:
|
||||
family = identify_family(device.name)
|
||||
if family is None:
|
||||
# Windows often omits the advertisement name for non-paired
|
||||
# devices — fall back to service UUID matching.
|
||||
family = identify_family_by_service_uuids(device.service_uuids)
|
||||
if family is not None:
|
||||
logger.debug(
|
||||
"BLE device %s (%s) identified by service UUID as %s",
|
||||
device.address,
|
||||
device.name,
|
||||
family,
|
||||
)
|
||||
if family is not None:
|
||||
display_name = f"{device.name} [{family}]"
|
||||
else:
|
||||
# Unknown device — include it so the user can add it manually.
|
||||
logger.debug(
|
||||
"BLE device %s (%s) does not match a known LED family",
|
||||
device.address,
|
||||
device.name,
|
||||
)
|
||||
display_name = device.name
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=display_name,
|
||||
url=f"ble://{device.address}",
|
||||
device_type="ble",
|
||||
ip=device.address,
|
||||
mac=device.address,
|
||||
led_count=None,
|
||||
version=None,
|
||||
ble_family=family,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Open a short-lived BLE session to toggle power, then close."""
|
||||
family = kwargs.get("ble_family")
|
||||
if not family:
|
||||
raise ValueError("BLE power control requires 'ble_family'")
|
||||
client = BLEClient(
|
||||
url=url, ble_family=family, ble_govee_key=kwargs.get("ble_govee_key", "")
|
||||
)
|
||||
try:
|
||||
await client.connect()
|
||||
await client.set_power(on)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
||||
"""Open a short-lived BLE session to set a solid color, then close."""
|
||||
family = kwargs.get("ble_family")
|
||||
if not family:
|
||||
raise ValueError("BLE color control requires 'ble_family'")
|
||||
brightness: int = kwargs.get("brightness", 255)
|
||||
client = BLEClient(
|
||||
url=url, ble_family=family, ble_govee_key=kwargs.get("ble_govee_key", "")
|
||||
)
|
||||
try:
|
||||
await client.connect()
|
||||
await client.send_pixels([color], brightness=brightness)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
# None of the supported BLE protocols expose a "get power state" read.
|
||||
# Treat as always-on so the UI doesn't show a misleading OFF indicator.
|
||||
return True
|
||||
|
||||
def list_families(self) -> List[dict]:
|
||||
"""Enumerate registered BLE protocol families (for the frontend device form)."""
|
||||
return [
|
||||
{"family": proto.family, "display_name": proto.display_name}
|
||||
for proto in all_protocols().values()
|
||||
]
|
||||
|
||||
|
||||
def get_ble_provider() -> Optional["BLEDeviceProvider"]:
|
||||
"""Return the registered BLE provider, or ``None`` if not registered."""
|
||||
from ledgrab.core.devices.led_client import get_provider
|
||||
|
||||
try:
|
||||
provider = get_provider("ble")
|
||||
except ValueError:
|
||||
return None
|
||||
return provider if isinstance(provider, BLEDeviceProvider) else None
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Thin async wrapper around ``bleak`` for LED-controller use.
|
||||
|
||||
Exists to:
|
||||
* Isolate the ``import bleak`` site so the rest of the codebase doesn't
|
||||
crash on platforms where bleak is unavailable (Chaquopy / Android).
|
||||
* Normalise addresses so UUID-on-macOS and MAC-on-Windows/Linux both
|
||||
work with the same API shape.
|
||||
* Coalesce rapid ``write()`` calls — BLE writes are O(tens of ms) each
|
||||
and LedGrab's hot loop runs at 60+ FPS, so we drop any pending write
|
||||
that has been superseded before it is sent.
|
||||
|
||||
Import-order note: ``bleak`` is imported lazily inside methods so the
|
||||
module itself imports cleanly on Android, where the whole BLE feature
|
||||
is effectively disabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.platform import is_android
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _bleak_available() -> bool:
|
||||
try:
|
||||
import bleak # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredBLEDevice:
|
||||
"""One BLE peripheral found during scanning."""
|
||||
|
||||
address: str
|
||||
name: str
|
||||
rssi: Optional[int]
|
||||
service_uuids: tuple = ()
|
||||
|
||||
|
||||
async def scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
|
||||
"""Scan for nearby BLE peripherals.
|
||||
|
||||
On Android dispatches to the Kotlin BleBridge scanner.
|
||||
On desktop uses bleak (requires the [ble] extra).
|
||||
|
||||
Returns devices sorted by RSSI descending (strongest first).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If neither backend is available.
|
||||
"""
|
||||
if is_android():
|
||||
from ledgrab.core.devices.android_ble_transport import android_ble_scan
|
||||
|
||||
return await android_ble_scan(timeout=timeout)
|
||||
|
||||
if not _bleak_available():
|
||||
raise RuntimeError(
|
||||
"bleak is not installed — BLE support requires the [ble] extra. "
|
||||
"Install with: pip install 'ledgrab[ble]'"
|
||||
)
|
||||
from bleak import BleakScanner
|
||||
|
||||
raw = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
||||
devices: List[DiscoveredBLEDevice] = []
|
||||
for address, (device, adv) in raw.items():
|
||||
# Some platforms don't surface names for non-advertising peripherals —
|
||||
# fall back to the address so the UI can still show something.
|
||||
name = adv.local_name or device.name or address
|
||||
rssi = getattr(adv, "rssi", None)
|
||||
service_uuids = tuple(getattr(adv, "service_uuids", None) or [])
|
||||
devices.append(
|
||||
DiscoveredBLEDevice(address=address, name=name, rssi=rssi, service_uuids=service_uuids)
|
||||
)
|
||||
devices.sort(key=lambda d: (d.rssi is None, -(d.rssi or 0)))
|
||||
return devices
|
||||
|
||||
|
||||
class BLETransport:
|
||||
"""Async wrapper around a ``BleakClient`` with write coalescing.
|
||||
|
||||
Lifecycle:
|
||||
transport = BLETransport(address, write_char_uuid)
|
||||
await transport.connect()
|
||||
await transport.write(b"...")
|
||||
await transport.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: str,
|
||||
write_char_uuid: str,
|
||||
write_with_response: bool = False,
|
||||
connect_timeout: float = 10.0,
|
||||
):
|
||||
self._address = address
|
||||
self._write_char_uuid = write_char_uuid
|
||||
self._write_with_response = write_with_response
|
||||
self._connect_timeout = connect_timeout
|
||||
self._client = None # BleakClient | None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._client is not None and bool(getattr(self._client, "is_connected", False))
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the peripheral.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If bleak is unavailable or connection fails.
|
||||
"""
|
||||
if not _bleak_available():
|
||||
raise RuntimeError("bleak is not installed — BLE support requires the [ble] extra.")
|
||||
from bleak import BleakClient
|
||||
|
||||
if self.is_connected:
|
||||
return
|
||||
|
||||
self._client = BleakClient(self._address, timeout=self._connect_timeout)
|
||||
try:
|
||||
# bleak's WinRT backend does not always respect the constructor
|
||||
# timeout — connect() can block 30s+ when the peripheral is gone.
|
||||
# Wrap in wait_for so the Python-side bound is enforced.
|
||||
await asyncio.wait_for(self._client.connect(), timeout=self._connect_timeout)
|
||||
except Exception as exc:
|
||||
self._client = None
|
||||
raise RuntimeError(f"Failed to connect to BLE device {self._address}: {exc}") from exc
|
||||
|
||||
logger.info("BLE connected to %s", self._address)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Disconnect (best effort — never raises)."""
|
||||
client = self._client
|
||||
self._client = None
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
if getattr(client, "is_connected", False):
|
||||
await client.disconnect()
|
||||
except Exception as exc:
|
||||
logger.warning("BLE disconnect of %s raised: %s", self._address, exc)
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
"""Send bytes to the configured write characteristic.
|
||||
|
||||
Serialised through an internal lock — BLE stacks do not like
|
||||
overlapping writes on the same GATT characteristic.
|
||||
|
||||
Bounded by a 2-second timeout: Windows/bleak occasionally hangs for
|
||||
its default 30s on the second write to certain cheap BLE LED chips.
|
||||
Timing out keeps the target's processing loop responsive.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not connected.
|
||||
TimeoutError: If the write does not complete within 2 seconds.
|
||||
"""
|
||||
if not self.is_connected or self._client is None:
|
||||
raise RuntimeError(f"BLE transport {self._address} not connected")
|
||||
async with self._lock:
|
||||
await asyncio.wait_for(
|
||||
self._client.write_gatt_char(
|
||||
self._write_char_uuid, data, response=self._write_with_response
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
|
||||
def make_transport(
|
||||
address: str,
|
||||
write_char_uuid: str,
|
||||
write_with_response: bool = False,
|
||||
connect_timeout: float = 10.0,
|
||||
) -> "BLETransport":
|
||||
"""Return the appropriate BLE transport for the current platform.
|
||||
|
||||
On Android returns an :class:`~ledgrab.core.devices.android_ble_transport.AndroidBLETransport`
|
||||
backed by the Kotlin ``BleBridge`` singleton. On desktop returns a
|
||||
:class:`BLETransport` backed by bleak.
|
||||
|
||||
The returned object has the same interface regardless of backend
|
||||
(``connect``, ``close``, ``write``, ``is_connected``, ``address``).
|
||||
"""
|
||||
if is_android():
|
||||
from ledgrab.core.devices.android_ble_transport import AndroidBLETransport
|
||||
|
||||
return AndroidBLETransport( # type: ignore[return-value]
|
||||
address=address,
|
||||
write_char_uuid=write_char_uuid,
|
||||
write_with_response=write_with_response,
|
||||
connect_timeout=connect_timeout,
|
||||
)
|
||||
return BLETransport(
|
||||
address=address,
|
||||
write_char_uuid=write_char_uuid,
|
||||
write_with_response=write_with_response,
|
||||
connect_timeout=connect_timeout,
|
||||
)
|
||||
@@ -47,6 +47,10 @@ class DiscoveredDevice:
|
||||
mac: str
|
||||
led_count: Optional[int]
|
||||
version: Optional[str]
|
||||
# Optional provider-specific detected protocol identifier (e.g. BLE family
|
||||
# like "sp110e" / "triones" / "zengge" / "govee"). Surfaced so the UI can
|
||||
# preselect the right sub-type when the user adds a discovered device.
|
||||
ble_family: Optional[str] = None
|
||||
|
||||
|
||||
class LEDClient(ABC):
|
||||
|
||||
@@ -1190,6 +1190,11 @@ class SystemMetricsValueStream(ValueStream):
|
||||
|
||||
Normalizes readings to [0, 1], with optional EMA smoothing and
|
||||
configurable poll interval.
|
||||
|
||||
On Android (Chaquopy), psutil is unavailable. The stream falls back
|
||||
to the platform-aware :func:`~ledgrab.utils.metrics.get_metrics_provider`
|
||||
for cpu/memory and returns 0.0 for desktop-only sensors (temps,
|
||||
fans, battery, network, disk, GPU).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -1219,16 +1224,23 @@ class SystemMetricsValueStream(ValueStream):
|
||||
self._prev_net_time: Optional[float] = None
|
||||
# GPU unavailable flag (avoid repeated warnings)
|
||||
self._gpu_unavailable = False
|
||||
# psutil may be unavailable on Android
|
||||
try:
|
||||
import psutil as _psutil
|
||||
|
||||
self._psutil = _psutil
|
||||
except ImportError:
|
||||
self._psutil = None
|
||||
|
||||
def start(self) -> None:
|
||||
import psutil
|
||||
|
||||
if self._psutil is None:
|
||||
return
|
||||
# Prime cpu_percent so the first real call returns meaningful data
|
||||
if self._metric == "cpu_load":
|
||||
psutil.cpu_percent(interval=None)
|
||||
self._psutil.cpu_percent(interval=None)
|
||||
# Prime network counters
|
||||
if self._metric in ("network_rx", "network_tx"):
|
||||
counters = psutil.net_io_counters()
|
||||
counters = self._psutil.net_io_counters()
|
||||
if counters:
|
||||
self._prev_net_bytes = (
|
||||
counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
|
||||
@@ -1280,34 +1292,64 @@ class SystemMetricsValueStream(ValueStream):
|
||||
return 0.0
|
||||
|
||||
def _read_metric(self) -> float:
|
||||
"""Read the raw metric value from the system."""
|
||||
import psutil
|
||||
"""Read the raw metric value from the system.
|
||||
|
||||
When psutil is unavailable (Android), falls back to the
|
||||
platform-aware MetricsProvider for cpu/memory and returns 0.0
|
||||
for desktop-only metrics.
|
||||
"""
|
||||
try:
|
||||
if self._metric == "cpu_load":
|
||||
return psutil.cpu_percent(interval=None)
|
||||
elif self._metric == "ram_usage":
|
||||
return psutil.virtual_memory().percent
|
||||
elif self._metric == "disk_usage":
|
||||
return psutil.disk_usage(self._disk_path).percent
|
||||
elif self._metric == "battery_level":
|
||||
bat = psutil.sensors_battery()
|
||||
return bat.percent if bat else 0.0
|
||||
elif self._metric == "cpu_temp":
|
||||
return self._read_cpu_temp()
|
||||
elif self._metric == "fan_speed":
|
||||
return self._read_fan_speed()
|
||||
elif self._metric in ("gpu_load", "gpu_temp"):
|
||||
return self._read_gpu_metric()
|
||||
elif self._metric in ("network_rx", "network_tx"):
|
||||
return self._read_network_rate()
|
||||
if self._psutil is not None:
|
||||
return self._read_metric_psutil()
|
||||
return self._read_metric_fallback()
|
||||
except Exception as e:
|
||||
logger.debug("SystemMetricsValueStream read error (%s): %s", self._metric, e)
|
||||
return self._raw_value if self._raw_value is not None else 0.0
|
||||
|
||||
def _read_cpu_temp(self) -> float:
|
||||
import psutil
|
||||
def _read_metric_psutil(self) -> float:
|
||||
"""Read metrics via psutil (desktop path)."""
|
||||
psutil = self._psutil
|
||||
if self._metric == "cpu_load":
|
||||
return psutil.cpu_percent(interval=None)
|
||||
elif self._metric == "ram_usage":
|
||||
return psutil.virtual_memory().percent
|
||||
elif self._metric == "disk_usage":
|
||||
return psutil.disk_usage(self._disk_path).percent
|
||||
elif self._metric == "battery_level":
|
||||
bat = psutil.sensors_battery()
|
||||
return bat.percent if bat else 0.0
|
||||
elif self._metric == "cpu_temp":
|
||||
return self._read_cpu_temp()
|
||||
elif self._metric == "fan_speed":
|
||||
return self._read_fan_speed()
|
||||
elif self._metric in ("gpu_load", "gpu_temp"):
|
||||
return self._read_gpu_metric()
|
||||
elif self._metric in ("network_rx", "network_tx"):
|
||||
return self._read_network_rate()
|
||||
return 0.0
|
||||
|
||||
def _read_metric_fallback(self) -> float:
|
||||
"""Read metrics without psutil (Android / fallback path).
|
||||
|
||||
Uses the MetricsProvider abstraction for cpu/memory. Sensors,
|
||||
battery, network, disk, and GPU are not available.
|
||||
"""
|
||||
from ledgrab.utils.metrics import get_metrics_provider
|
||||
|
||||
provider = get_metrics_provider()
|
||||
if self._metric == "cpu_load":
|
||||
return provider.cpu_percent()
|
||||
elif self._metric == "ram_usage":
|
||||
mem = provider.virtual_memory()
|
||||
if mem.total_bytes > 0:
|
||||
return (mem.used_bytes / mem.total_bytes) * 100.0
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
def _read_cpu_temp(self) -> float:
|
||||
psutil = self._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
temps = psutil.sensors_temperatures()
|
||||
if not temps:
|
||||
return 0.0
|
||||
@@ -1324,8 +1366,9 @@ class SystemMetricsValueStream(ValueStream):
|
||||
return 0.0
|
||||
|
||||
def _read_fan_speed(self) -> float:
|
||||
import psutil
|
||||
|
||||
psutil = self._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
fans = psutil.sensors_fans()
|
||||
if not fans:
|
||||
return 0.0
|
||||
@@ -1360,8 +1403,9 @@ class SystemMetricsValueStream(ValueStream):
|
||||
return 0.0
|
||||
|
||||
def _read_network_rate(self) -> float:
|
||||
import psutil
|
||||
|
||||
psutil = self._psutil
|
||||
if psutil is None:
|
||||
return 0.0
|
||||
counters = psutil.net_io_counters()
|
||||
if not counters:
|
||||
return 0.0
|
||||
|
||||
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.requests import Request
|
||||
|
||||
from ledgrab import __version__, GITEA_BASE_URL, GITEA_REPO
|
||||
from ledgrab import __version__, GITEA_BASE_URL, GITEA_REPO, paths as _paths
|
||||
from ledgrab.api import router
|
||||
from ledgrab.api.dependencies import init_dependencies
|
||||
from ledgrab.config import get_config
|
||||
@@ -70,6 +70,65 @@ logger = get_logger(__name__)
|
||||
# Get configuration
|
||||
config = get_config()
|
||||
|
||||
|
||||
def _migrate_legacy_data_location() -> None:
|
||||
"""Rescue data from pre-rename cwd-relative paths.
|
||||
|
||||
Older versions (and dev runs from inside ``server/``) wrote the database
|
||||
and assets to ``<cwd>/data/``. If the configured database location is
|
||||
empty but a legacy path has data, copy it over so the user's data
|
||||
follows them to the platform-standard location.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
db_path = Path(config.storage.database_file)
|
||||
if db_path.exists():
|
||||
return # configured location already populated — nothing to do
|
||||
|
||||
for legacy_db in _paths.legacy_db_candidates():
|
||||
if not legacy_db.is_file():
|
||||
continue
|
||||
try:
|
||||
# Skip if legacy is the same file we were going to open.
|
||||
if db_path.parent.exists() and legacy_db.resolve() == db_path.resolve():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
if legacy_db.stat().st_size < 4096:
|
||||
# 4 KiB is roughly a freshly-initialised SQLite file with no
|
||||
# user data — skip so an empty dev DB doesn't shadow a real one.
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"Migrating database from legacy location %s -> %s. "
|
||||
"The original file is kept in place; you may delete it once you "
|
||||
"confirm the new location works.",
|
||||
legacy_db,
|
||||
db_path,
|
||||
)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(legacy_db, db_path)
|
||||
# Copy WAL/SHM side-files too so uncheckpointed writes come along.
|
||||
for suffix in ("-wal", "-shm"):
|
||||
side = legacy_db.with_name(legacy_db.name + suffix)
|
||||
if side.exists():
|
||||
shutil.copy2(side, db_path.with_name(db_path.name + suffix))
|
||||
|
||||
# Also migrate assets dir if the configured one is missing.
|
||||
assets_dir = Path(config.assets.assets_dir)
|
||||
legacy_assets = legacy_db.parent / "assets"
|
||||
if not assets_dir.exists() and legacy_assets.is_dir():
|
||||
logger.warning(
|
||||
"Migrating assets from legacy location %s -> %s",
|
||||
legacy_assets,
|
||||
assets_dir,
|
||||
)
|
||||
shutil.copytree(legacy_assets, assets_dir)
|
||||
return
|
||||
|
||||
|
||||
_migrate_legacy_data_location()
|
||||
|
||||
# Initialize SQLite database
|
||||
db = Database(config.storage.database_file)
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Default data directory resolution.
|
||||
|
||||
Each LedGrab install/checkout uses its own data directory by default —
|
||||
a cwd-relative ``data`` folder — so running two versions side-by-side does
|
||||
not mix their databases together.
|
||||
|
||||
Precedence:
|
||||
1. ``LEDGRAB_STORAGE__DATABASE_FILE`` / ``LEDGRAB_ASSETS__ASSETS_DIR`` env
|
||||
vars (used by the Android entry point and fine-grained overrides).
|
||||
2. ``storage.database_file`` / ``assets.assets_dir`` in config.yaml.
|
||||
3. ``LEDGRAB_DATA_DIR`` env var (one-line override for the whole data
|
||||
dir — useful for dev launchers that want to isolate from prod).
|
||||
4. ``./data`` relative to the process working directory.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_ENV_DATA_DIR = "LEDGRAB_DATA_DIR"
|
||||
|
||||
|
||||
def default_data_dir() -> Path:
|
||||
"""Return the directory where data files live by default.
|
||||
|
||||
Honours the ``LEDGRAB_DATA_DIR`` env var; otherwise returns ``./data``.
|
||||
Callers should treat the result as the *parent* of ``ledgrab.db`` and
|
||||
``assets/``.
|
||||
"""
|
||||
override = os.environ.get(_ENV_DATA_DIR)
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path("data")
|
||||
|
||||
|
||||
def legacy_db_candidates() -> list[Path]:
|
||||
"""Return cwd-relative database paths that predate :func:`default_data_dir`.
|
||||
|
||||
Used by the startup migration in ``main.py`` to rescue data that was
|
||||
previously written by older versions (or by dev runs from inside ``server/``).
|
||||
Order matters: first existing match wins.
|
||||
"""
|
||||
cwd = Path.cwd()
|
||||
return [
|
||||
cwd / "data" / "ledgrab.db",
|
||||
cwd / "server" / "data" / "ledgrab.db",
|
||||
]
|
||||
|
||||
|
||||
def legacy_assets_candidates() -> list[Path]:
|
||||
"""Return cwd-relative assets directories paired with :func:`legacy_db_candidates`."""
|
||||
cwd = Path.cwd()
|
||||
return [
|
||||
cwd / "data" / "assets",
|
||||
cwd / "server" / "data" / "assets",
|
||||
]
|
||||
@@ -2268,3 +2268,41 @@ body.composite-layer-dragging .composite-layer-drag-handle {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Inline code + copyable snippet used by the setup-required modal */
|
||||
.code-snippet-wrapper {
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.code-snippet {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 10px 44px 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono, 'Consolas', 'Courier New', monospace);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.45;
|
||||
color: var(--text-color, #e0e0e0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-snippet code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.code-snippet-wrapper .copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
padding: 4px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// Layer 0: state
|
||||
import { apiKey, setApiKey, authRequired, refreshInterval } from './core/state.ts';
|
||||
import { apiKey, setApiKey, authRequired, refreshInterval, setupRequired } from './core/state.ts';
|
||||
import { Modal } from './core/modal.ts';
|
||||
import { queryEl } from './core/dom-utils.ts';
|
||||
|
||||
@@ -695,8 +695,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize locale (dispatches languageChanged which may trigger API calls)
|
||||
await initLocale();
|
||||
|
||||
// Load external URL setting early so getBaseOrigin() is available for card rendering
|
||||
loadExternalUrl();
|
||||
// Probe /health first so we know whether the server has API keys configured
|
||||
// AND whether this client is loopback or LAN. The result (setup_required
|
||||
// and auth_required flags) gates every subsequent call; without it, a LAN
|
||||
// client without keys flashes a useless login modal before the setup
|
||||
// screen can take over.
|
||||
await loadServerInfo();
|
||||
|
||||
// Load external URL setting early so getBaseOrigin() is available for card
|
||||
// rendering — but skip when the server has no keys for LAN access, as the
|
||||
// call would just 401 and trigger the login modal behind the setup screen.
|
||||
if (!setupRequired) {
|
||||
loadExternalUrl();
|
||||
}
|
||||
|
||||
// Restore active tab before showing content to avoid visible jump
|
||||
initTabs();
|
||||
@@ -750,14 +761,20 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const addDeviceForm = queryEl('add-device-form');
|
||||
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
|
||||
|
||||
// Always monitor server connection (even before login)
|
||||
await loadServerInfo();
|
||||
// Keep monitoring server connection (initial /health ran earlier).
|
||||
startConnectionMonitor();
|
||||
|
||||
// Expose auth state for inline scripts (after loadServerInfo sets it)
|
||||
(window as any)._authRequired = authRequired;
|
||||
if (typeof window.updateAuthUI === 'function') window.updateAuthUI();
|
||||
|
||||
// Server is unconfigured for LAN access → setup screen already shown by
|
||||
// loadServerInfo. Skip login modal and data loads; the user can't do
|
||||
// anything until they configure keys on the server.
|
||||
if (setupRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show login modal only when auth is enabled and no API key is stored
|
||||
if (authRequired && !apiKey) {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* API utilities — base URL, auth headers, fetch wrapper, helpers.
|
||||
*/
|
||||
|
||||
import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { apiKey, setApiKey, authRequired, setAuthRequired, setupRequired, setSetupRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { showToast } from './ui.ts';
|
||||
import { getEl, queryEl } from './dom-utils.ts';
|
||||
@@ -175,8 +175,16 @@ export function isGroupDevice(type: string) {
|
||||
return type === 'group';
|
||||
}
|
||||
|
||||
export function isBleDevice(type: string) {
|
||||
return type === 'ble';
|
||||
}
|
||||
|
||||
export function handle401Error() {
|
||||
if (!authRequired) return; // Auth disabled — ignore 401s
|
||||
// Server has no keys configured and we're on LAN: the setup-required
|
||||
// screen is (or is about to be) shown by loadServerInfo. Don't pop a
|
||||
// login modal on top of it — no key would ever work.
|
||||
if (setupRequired) return;
|
||||
if (!apiKey) return; // Already handled or no session
|
||||
localStorage.removeItem('ledgrab_api_key');
|
||||
setApiKey(null);
|
||||
@@ -278,6 +286,21 @@ export async function loadServerInfo() {
|
||||
setAuthRequired(authNeeded);
|
||||
(window as any)._authRequired = authNeeded;
|
||||
|
||||
// Setup-required detection (LAN client + no keys configured server-side).
|
||||
// When true, no API key will ever succeed — show a dedicated screen
|
||||
// instead of the login form.
|
||||
const setupNeeded = data.setup_required === true;
|
||||
setSetupRequired(setupNeeded);
|
||||
(window as any)._setupRequired = setupNeeded;
|
||||
if (setupNeeded) {
|
||||
if (typeof window.showSetupRequiredModal === 'function') {
|
||||
window.showSetupRequiredModal();
|
||||
}
|
||||
} else if (typeof window.hideSetupRequiredModal === 'function') {
|
||||
// Server was reconfigured — clear the setup overlay if it was up.
|
||||
if ((window as any)._setupModalOpen) window.hideSetupRequiredModal();
|
||||
}
|
||||
|
||||
// Project URLs (repo, donate)
|
||||
if (data.repo_url) serverRepoUrl = data.repo_url;
|
||||
if (data.donate_url) serverDonateUrl = data.donate_url;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const star = '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.67
|
||||
export const hash = '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>';
|
||||
export const camera = '<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>';
|
||||
export const bellRing = '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/>';
|
||||
export const bluetooth = '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>';
|
||||
export const wrench = '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>';
|
||||
export const music = '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>';
|
||||
export const search = '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>';
|
||||
|
||||
@@ -50,6 +50,7 @@ const _deviceTypeIcons = {
|
||||
dmx: _svg(P.radio), mock: _svg(P.wrench),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), usbhid: _svg(P.usb),
|
||||
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
|
||||
ble: _svg(P.bluetooth),
|
||||
group: _svg(P.layers),
|
||||
};
|
||||
const _engineTypeIcons = {
|
||||
@@ -321,6 +322,8 @@ export const ICON_UNDO = _svg(P.undo2);
|
||||
export const ICON_SCENE = _svg(P.sparkles);
|
||||
export const ICON_CAPTURE = _svg(P.camera);
|
||||
export const ICON_BELL = _svg(P.bellRing);
|
||||
export const ICON_BLUETOOTH = _svg(P.bluetooth);
|
||||
export const ICON_LIGHTBULB = _svg(P.lightbulb);
|
||||
export const ICON_THERMOMETER = _svg(P.thermometer);
|
||||
export const ICON_CPU = _svg(P.cpu);
|
||||
export const ICON_KEYBOARD = _svg(P.keyboard);
|
||||
|
||||
@@ -28,6 +28,15 @@ export function setApiKey(v: string | null) { apiKey = v; }
|
||||
export let authRequired = true;
|
||||
export function setAuthRequired(v: boolean) { authRequired = v; }
|
||||
|
||||
/**
|
||||
* True when the server reports it has no API keys configured AND the request
|
||||
* is coming from a non-loopback client. In that state, no key can succeed —
|
||||
* the UI should show a dedicated "setup required" screen instead of the
|
||||
* login form.
|
||||
*/
|
||||
export let setupRequired = false;
|
||||
export function setSetupRequired(v: boolean) { setupRequired = v; }
|
||||
|
||||
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { _computeMaxFps, _renderFpsHint } from './devices.ts';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY } from '../core/icons.ts';
|
||||
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES } from '../core/icons.ts';
|
||||
import { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
|
||||
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
|
||||
|
||||
@@ -36,6 +36,8 @@ class AddDeviceModal extends Modal {
|
||||
dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet',
|
||||
dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0',
|
||||
dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1',
|
||||
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
|
||||
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '',
|
||||
groupChildren: JSON.stringify(_getGroupChildIds('device')),
|
||||
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
|
||||
};
|
||||
@@ -46,7 +48,7 @@ const addDeviceModal = new AddDeviceModal();
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
|
||||
|
||||
function _buildDeviceTypeItems() {
|
||||
return DEVICE_TYPE_KEYS.map(key => ({
|
||||
@@ -229,6 +231,7 @@ export function onDeviceTypeChanged() {
|
||||
// Hide new device type fields by default
|
||||
_showEspnowFields(false);
|
||||
_showHueFields(false);
|
||||
_showBleFields(false);
|
||||
_showSpiFields(false);
|
||||
_showChromaFields(false);
|
||||
_showGameSenseFields(false);
|
||||
@@ -383,6 +386,28 @@ export function onDeviceTypeChanged() {
|
||||
} else {
|
||||
scanForDevices();
|
||||
}
|
||||
} else if (isBleDevice(deviceType)) {
|
||||
// BLE: show URL (ble://<address>), LED count, protocol family picker,
|
||||
// and a Govee-only AES key field that toggles with the family selection.
|
||||
urlGroup.style.display = '';
|
||||
urlInput.setAttribute('required', '');
|
||||
serialGroup.style.display = 'none';
|
||||
serialSelect.removeAttribute('required');
|
||||
ledCountGroup.style.display = '';
|
||||
baudRateGroup.style.display = 'none';
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||
if (scanBtn) scanBtn.style.display = '';
|
||||
_showBleFields(true);
|
||||
_ensureBleFamilyIconSelect();
|
||||
if (urlLabel) urlLabel.textContent = t('device.ble.url') || 'BLE Address';
|
||||
if (urlHint) urlHint.textContent = t('device.ble.url.hint') || 'MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://';
|
||||
urlInput.placeholder = 'ble://AA:BB:CC:DD:EE:FF';
|
||||
if (deviceType in _discoveryCache) {
|
||||
_renderDiscoveryList();
|
||||
} else {
|
||||
scanForDevices();
|
||||
}
|
||||
} else if (isUsbhidDevice(deviceType)) {
|
||||
// USB HID: show URL (VID:PID), LED count
|
||||
urlGroup.style.display = '';
|
||||
@@ -666,6 +691,17 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
||||
const sendLatencyEl = document.getElementById('device-send-latency') as HTMLInputElement;
|
||||
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
|
||||
}
|
||||
// Prefill BLE fields
|
||||
if (isBleDevice(presetType)) {
|
||||
const bleFamilyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
|
||||
if (bleFamilyEl && cloneData.ble_family) {
|
||||
bleFamilyEl.value = cloneData.ble_family;
|
||||
if (_bleFamilyIconSelect) _bleFamilyIconSelect.setValue(cloneData.ble_family);
|
||||
}
|
||||
const goveeKeyEl = document.getElementById('device-ble-govee-key') as HTMLInputElement;
|
||||
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key;
|
||||
_updateBleGoveeKeyVisibility();
|
||||
}
|
||||
// Prefill DMX fields
|
||||
if (isDmxDevice(presetType)) {
|
||||
const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
|
||||
@@ -727,7 +763,8 @@ export async function scanForDevices(forceType?: any) {
|
||||
if (scanBtn) scanBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`);
|
||||
const scanTimeout = scanType === 'ble' ? 8 : 3;
|
||||
const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
|
||||
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
@@ -778,6 +815,16 @@ export function selectDiscoveredDevice(device: any) {
|
||||
if (isOpenrgbDevice(device.device_type)) {
|
||||
_fetchOpenrgbZones(device.url, 'device-zone-list');
|
||||
}
|
||||
// Auto-fill the BLE protocol family detected during discovery so the
|
||||
// user doesn't silently get the default (sp110e) against a different
|
||||
// controller. Wrong family → writes go to a non-existent GATT
|
||||
// characteristic and the strip stays dark.
|
||||
if (isBleDevice(device.device_type) && device.ble_family) {
|
||||
const familyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
|
||||
if (familyEl) familyEl.value = device.ble_family;
|
||||
if (_bleFamilyIconSelect) _bleFamilyIconSelect.setValue(device.ble_family);
|
||||
_updateBleGoveeKeyVisibility();
|
||||
}
|
||||
showToast(t('device.scan.selected'), 'info');
|
||||
}
|
||||
|
||||
@@ -859,6 +906,11 @@ export async function handleAddDevice(event: any) {
|
||||
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
|
||||
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
|
||||
}
|
||||
if (isBleDevice(deviceType)) {
|
||||
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
|
||||
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
|
||||
if (goveeKey) body.ble_govee_key = goveeKey;
|
||||
}
|
||||
if (isSpiDevice(deviceType)) {
|
||||
body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
|
||||
body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
|
||||
@@ -889,7 +941,7 @@ export async function handleAddDevice(event: any) {
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Device added successfully:', result);
|
||||
// result is logged by the API layer; no console.log here.
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
@@ -1207,6 +1259,56 @@ function _showHueFields(show: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
// Tracks whether the BLE fields are currently shown — avoids reading
|
||||
// style.display strings in _updateBleGoveeKeyVisibility.
|
||||
let _bleFieldsVisible = false;
|
||||
|
||||
function _showBleFields(show: boolean) {
|
||||
_bleFieldsVisible = show;
|
||||
const familyGroup = document.getElementById('device-ble-family-group') as HTMLElement;
|
||||
if (familyGroup) familyGroup.style.display = show ? '' : 'none';
|
||||
if (!show) _destroyBleFamilyIconSelect();
|
||||
_updateBleGoveeKeyVisibility();
|
||||
}
|
||||
|
||||
function _updateBleGoveeKeyVisibility() {
|
||||
const family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value;
|
||||
const goveeGroup = document.getElementById('device-ble-govee-key-group') as HTMLElement;
|
||||
if (goveeGroup) goveeGroup.style.display = _bleFieldsVisible && family === 'govee' ? '' : 'none';
|
||||
}
|
||||
|
||||
function _buildBleFamilyItems() {
|
||||
return [
|
||||
{ value: 'sp110e', icon: ICON_CPU, label: 'SP110E / SP108E', desc: t('device.ble.family.sp110e.desc') },
|
||||
{ value: 'triones', icon: ICON_BLUETOOTH, label: 'Triones / HappyLighting / LEDnet', desc: t('device.ble.family.triones.desc') },
|
||||
{ value: 'zengge', icon: ICON_LIGHTBULB, label: 'Zengge / iLightsIn', desc: t('device.ble.family.zengge.desc') },
|
||||
{ value: 'govee', icon: ICON_BLUETOOTH, label: 'Govee (experimental)', desc: t('device.ble.family.govee.desc') },
|
||||
];
|
||||
}
|
||||
|
||||
let _bleFamilyIconSelect: any = null;
|
||||
|
||||
function _destroyBleFamilyIconSelect() {
|
||||
if (_bleFamilyIconSelect) {
|
||||
_bleFamilyIconSelect.destroy();
|
||||
_bleFamilyIconSelect = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureBleFamilyIconSelect() {
|
||||
const sel = document.getElementById('device-ble-family') as HTMLSelectElement;
|
||||
if (!sel) return;
|
||||
if (_bleFamilyIconSelect) {
|
||||
_bleFamilyIconSelect.updateItems(_buildBleFamilyItems());
|
||||
} else {
|
||||
_bleFamilyIconSelect = new IconSelect({ target: sel, items: _buildBleFamilyItems(), columns: 2 } as any);
|
||||
// Register once — native <select> change fires when IconSelect picks a value,
|
||||
// which is what toggles the Govee key field.
|
||||
sel.addEventListener('change', _updateBleGoveeKeyVisibility);
|
||||
}
|
||||
_updateBleGoveeKeyVisibility();
|
||||
}
|
||||
|
||||
function _showSpiFields(show: boolean) {
|
||||
const ids = ['device-spi-speed-group', 'device-spi-led-type-group'];
|
||||
ids.forEach(id => {
|
||||
|
||||
+2
@@ -15,6 +15,8 @@ interface Window {
|
||||
// ─── Auth (set by inline <script> in index.html) ───
|
||||
updateAuthUI: (() => void) | undefined;
|
||||
showApiKeyModal: ((msg: string | null, force?: boolean) => void) | undefined;
|
||||
showSetupRequiredModal: (() => void) | undefined;
|
||||
hideSetupRequiredModal: (() => void) | undefined;
|
||||
|
||||
// ─── Core / state ───
|
||||
setApiKey: (key: string | null) => void;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"auth.button.cancel": "Cancel",
|
||||
"auth.button.login": "Login",
|
||||
"auth.error.required": "Please enter an API key",
|
||||
"auth.error.invalid": "Invalid API key. Please try again.",
|
||||
"auth.success": "Logged in successfully!",
|
||||
"auth.logout.confirm": "Are you sure you want to logout?",
|
||||
"auth.logout.success": "Logged out successfully",
|
||||
@@ -36,6 +37,16 @@
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.toggle_password": "Toggle password visibility",
|
||||
"api_key.login": "Login",
|
||||
"setup.title": "Server setup required",
|
||||
"setup.description": "This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.",
|
||||
"setup.step1_label": "1. On the server machine, edit <code>config/default_config.yaml</code>:",
|
||||
"setup.step2_label": "2. Restart the server, then reload this page and log in with that key.",
|
||||
"setup.step3_label": "Alternative: open LedGrab from the server machine itself (loopback), no key required:",
|
||||
"setup.hint_openssl": "Generate a strong key on Linux/macOS with <code>openssl rand -hex 32</code>, or on Windows PowerShell with <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.",
|
||||
"setup.copy": "Copy snippet",
|
||||
"setup.copied": "Copied to clipboard",
|
||||
"setup.retry": "I've configured a key — retry",
|
||||
"setup.still_required": "Server still reports no API keys. Make sure you saved the config file and restarted the server.",
|
||||
"displays.title": "Available Displays",
|
||||
"displays.layout": "Displays",
|
||||
"displays.information": "Display Information",
|
||||
@@ -168,6 +179,19 @@
|
||||
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
||||
"device.type.hue": "Philips Hue",
|
||||
"device.type.hue.desc": "Hue Entertainment API streaming",
|
||||
"device.type.ble": "BLE LED Controller",
|
||||
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
|
||||
"device.ble.url": "BLE Address:",
|
||||
"device.ble.url.hint": "MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://",
|
||||
"device.ble.family": "Protocol Family:",
|
||||
"device.ble.family.hint": "Which BLE protocol your controller speaks. Match the phone app you normally use.",
|
||||
"device.ble.family.sp110e.desc": "Addressable controllers — LED Hue / SP110E app",
|
||||
"device.ble.family.triones.desc": "Single-color controllers — HappyLighting / LEDnet app",
|
||||
"device.ble.family.zengge.desc": "Single-color controllers — iLightsIn / Mohuan app",
|
||||
"device.ble.family.govee.desc": "Govee H6xxx strips — unencrypted firmware only",
|
||||
"device.ble.govee_key": "Govee AES Key (hex):",
|
||||
"device.ble.govee_key.hint": "Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.",
|
||||
"device.ble.govee_key.placeholder": "32 hex digits, e.g. 0102…1f20",
|
||||
"device.type.usbhid": "USB HID",
|
||||
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
|
||||
"device.type.spi": "SPI Direct",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"auth.button.cancel": "Отмена",
|
||||
"auth.button.login": "Войти",
|
||||
"auth.error.required": "Пожалуйста, введите API ключ",
|
||||
"auth.error.invalid": "Неверный API ключ. Попробуйте ещё раз.",
|
||||
"auth.success": "Вход выполнен успешно!",
|
||||
"auth.logout.confirm": "Вы уверены, что хотите выйти?",
|
||||
"auth.logout.success": "Выход выполнен успешно",
|
||||
@@ -40,6 +41,16 @@
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
|
||||
"api_key.login": "Войти",
|
||||
"setup.title": "Требуется настройка сервера",
|
||||
"setup.description": "На этом сервере LedGrab не настроены API-ключи, поэтому доступ с других устройств в сети отключён из соображений безопасности. Чтобы разрешить доступ по локальной сети, настройте ключ на машине, где работает сервер.",
|
||||
"setup.step1_label": "1. На машине с сервером откройте <code>config/default_config.yaml</code>:",
|
||||
"setup.step2_label": "2. Перезапустите сервер, затем перезагрузите эту страницу и войдите с этим ключом.",
|
||||
"setup.step3_label": "Либо откройте LedGrab прямо на машине с сервером (через loopback), ключ не нужен:",
|
||||
"setup.hint_openssl": "Сгенерировать надёжный ключ: Linux/macOS — <code>openssl rand -hex 32</code>; Windows PowerShell — <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.",
|
||||
"setup.copy": "Скопировать фрагмент",
|
||||
"setup.copied": "Скопировано в буфер обмена",
|
||||
"setup.retry": "Я настроил ключ — проверить снова",
|
||||
"setup.still_required": "Сервер всё ещё сообщает об отсутствии API-ключей. Проверьте, что файл сохранён и сервер перезапущен.",
|
||||
"displays.title": "Доступные Дисплеи",
|
||||
"displays.layout": "Дисплеи",
|
||||
"displays.information": "Информация о Дисплеях",
|
||||
@@ -172,6 +183,19 @@
|
||||
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
||||
"device.type.hue": "Philips Hue",
|
||||
"device.type.hue.desc": "Hue Entertainment API streaming",
|
||||
"device.type.ble": "BLE LED контроллер",
|
||||
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
|
||||
"device.ble.url": "BLE адрес:",
|
||||
"device.ble.url.hint": "MAC-адрес (Windows/Linux) или UUID (macOS) с префиксом ble://",
|
||||
"device.ble.family": "Протокол:",
|
||||
"device.ble.family.hint": "Какой BLE-протокол использует контроллер. Выбирайте по названию приложения, которым обычно управляете.",
|
||||
"device.ble.family.sp110e.desc": "Адресуемые контроллеры — приложение LED Hue / SP110E",
|
||||
"device.ble.family.triones.desc": "Одноцветные контроллеры — HappyLighting / LEDnet",
|
||||
"device.ble.family.zengge.desc": "Одноцветные контроллеры — iLightsIn / Mohuan",
|
||||
"device.ble.family.govee.desc": "Ленты Govee H6xxx — только без AES-шифрования",
|
||||
"device.ble.govee_key": "Ключ AES Govee (hex):",
|
||||
"device.ble.govee_key.hint": "Необязательно. Новая прошивка Govee требует AES-ключ под конкретную модель — оставьте пустым для старой прошивки.",
|
||||
"device.ble.govee_key.placeholder": "32 символа hex, напр. 0102…1f20",
|
||||
"device.type.usbhid": "USB HID",
|
||||
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
|
||||
"device.type.spi": "SPI Direct",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"auth.button.cancel": "取消",
|
||||
"auth.button.login": "登录",
|
||||
"auth.error.required": "请输入 API 密钥",
|
||||
"auth.error.invalid": "API 密钥无效,请重试。",
|
||||
"auth.success": "登录成功!",
|
||||
"auth.logout.confirm": "确定要退出登录吗?",
|
||||
"auth.logout.success": "已成功退出",
|
||||
@@ -40,6 +41,16 @@
|
||||
"auth.prompt_enter": "Enter your API key:",
|
||||
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
|
||||
"api_key.login": "登录",
|
||||
"setup.title": "服务器需要配置",
|
||||
"setup.description": "此 LedGrab 服务器未配置 API 密钥,出于安全考虑已禁用来自网络其他设备的访问。请在运行服务器的机器上配置密钥以启用局域网访问。",
|
||||
"setup.step1_label": "1. 在服务器所在机器上,编辑 <code>config/default_config.yaml</code>:",
|
||||
"setup.step2_label": "2. 重启服务器,然后刷新此页面并使用该密钥登录。",
|
||||
"setup.step3_label": "或者:直接在服务器本机打开 LedGrab(回环地址),无需密钥:",
|
||||
"setup.hint_openssl": "生成强密钥:Linux/macOS 使用 <code>openssl rand -hex 32</code>;Windows PowerShell 使用 <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>。",
|
||||
"setup.copy": "复制代码片段",
|
||||
"setup.copied": "已复制到剪贴板",
|
||||
"setup.retry": "我已配置密钥 — 重试",
|
||||
"setup.still_required": "服务器仍报告未配置 API 密钥。请确认已保存配置文件并重启服务器。",
|
||||
"displays.title": "可用显示器",
|
||||
"displays.layout": "显示器",
|
||||
"displays.information": "显示器信息",
|
||||
@@ -172,6 +183,19 @@
|
||||
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
||||
"device.type.hue": "Philips Hue",
|
||||
"device.type.hue.desc": "Hue Entertainment API streaming",
|
||||
"device.type.ble": "BLE LED 控制器",
|
||||
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
|
||||
"device.ble.url": "BLE 地址:",
|
||||
"device.ble.url.hint": "MAC 地址(Windows/Linux)或 UUID(macOS),加前缀 ble://",
|
||||
"device.ble.family": "协议:",
|
||||
"device.ble.family.hint": "控制器使用哪种 BLE 协议。按你平时使用的手机应用选择。",
|
||||
"device.ble.family.sp110e.desc": "可编址控制器 — LED Hue / SP110E 应用",
|
||||
"device.ble.family.triones.desc": "单色控制器 — HappyLighting / LEDnet",
|
||||
"device.ble.family.zengge.desc": "单色控制器 — iLightsIn / Mohuan",
|
||||
"device.ble.family.govee.desc": "Govee H6xxx 灯带 — 仅支持未加密固件",
|
||||
"device.ble.govee_key": "Govee AES 密钥(hex):",
|
||||
"device.ble.govee_key.hint": "可选。新版 Govee 固件需要按型号的 AES 密钥 — 老固件留空即可。",
|
||||
"device.ble.govee_key.placeholder": "32位十六进制,如 0102…1f20",
|
||||
"device.type.usbhid": "USB HID",
|
||||
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
|
||||
"device.type.spi": "SPI Direct",
|
||||
|
||||
@@ -231,17 +231,92 @@ class AssetStore(BaseSqliteStore[Asset]):
|
||||
logger.info(f"Restored prebuilt asset: {asset.name} ({asset.id})")
|
||||
return restored
|
||||
|
||||
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
|
||||
"""Import prebuilt sound files that don't already exist as assets.
|
||||
def heal_prebuilt_files(self, prebuilt_dir: Path) -> List[Asset]:
|
||||
"""Re-copy shipped files for prebuilt assets whose on-disk file is missing.
|
||||
|
||||
Called on startup. Skips files that are already imported (by original
|
||||
filename match with prebuilt=True), including soft-deleted ones.
|
||||
Covers the case where the database row still exists (``prebuilt=True``,
|
||||
``deleted=False``) but ``stored_filename`` has been lost — e.g. after a
|
||||
partial restore from backup, a data-dir move, or manual file deletion.
|
||||
Contrast with :meth:`restore_prebuilt`, which only handles the
|
||||
user-initiated soft-delete flow.
|
||||
|
||||
Returns list of newly imported assets.
|
||||
Returns list of healed assets.
|
||||
"""
|
||||
if not prebuilt_dir.exists():
|
||||
return []
|
||||
|
||||
healed: List[Asset] = []
|
||||
for asset in self._items.values():
|
||||
if not asset.prebuilt or asset.deleted:
|
||||
continue
|
||||
dest = self._assets_dir / asset.stored_filename
|
||||
if dest.exists():
|
||||
continue
|
||||
src = prebuilt_dir / asset.filename
|
||||
if not src.exists():
|
||||
logger.warning(
|
||||
"Prebuilt asset %r (%s) is missing on disk and its source "
|
||||
"%s is also missing — cannot heal.",
|
||||
asset.name,
|
||||
asset.id,
|
||||
src,
|
||||
)
|
||||
continue
|
||||
shutil.copy2(src, dest)
|
||||
asset.size_bytes = dest.stat().st_size
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(asset.id, asset)
|
||||
healed.append(asset)
|
||||
logger.warning(
|
||||
"Healed missing prebuilt asset %r (%s) -> %s",
|
||||
asset.name,
|
||||
asset.id,
|
||||
asset.stored_filename,
|
||||
)
|
||||
return healed
|
||||
|
||||
def warn_missing_custom_files(self) -> List[Asset]:
|
||||
"""Log a warning for each non-prebuilt asset whose file is missing.
|
||||
|
||||
Custom files cannot be auto-healed (there's no authoritative source),
|
||||
but surfacing them on startup makes the state of the data visible
|
||||
rather than only appearing as 404s when the UI tries to play them.
|
||||
|
||||
Returns the list of assets with missing files.
|
||||
"""
|
||||
missing: List[Asset] = []
|
||||
for asset in self._items.values():
|
||||
if asset.prebuilt or asset.deleted:
|
||||
continue
|
||||
if not (self._assets_dir / asset.stored_filename).exists():
|
||||
missing.append(asset)
|
||||
if missing:
|
||||
logger.warning(
|
||||
"%d custom asset file(s) missing on disk — the UI will return "
|
||||
"404 when trying to load them. Re-upload or delete the "
|
||||
"orphan records: %s",
|
||||
len(missing),
|
||||
", ".join(f"{a.name} ({a.id})" for a in missing),
|
||||
)
|
||||
return missing
|
||||
|
||||
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
|
||||
"""Import prebuilt sound files that don't already exist as assets.
|
||||
|
||||
Called on startup. Also heals prebuilt assets whose metadata row
|
||||
exists but whose stored file is missing on disk, and logs a warning
|
||||
for any custom assets in the same situation.
|
||||
|
||||
Returns list of newly imported assets (not healed or missing ones).
|
||||
"""
|
||||
if not prebuilt_dir.exists():
|
||||
return []
|
||||
|
||||
# Heal first so a freshly-restored DB with missing prebuilt files
|
||||
# recovers before the UI has a chance to request them.
|
||||
self.heal_prebuilt_files(prebuilt_dir)
|
||||
self.warn_missing_custom_files()
|
||||
|
||||
# Build set of known prebuilt filenames (including deleted ones)
|
||||
known_filenames = {a.filename for a in self._items.values() if a.prebuilt}
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
{% include 'modals/notification-history.html' %}
|
||||
{% include 'modals/pattern-template.html' %}
|
||||
{% include 'modals/api-key.html' %}
|
||||
{% include 'modals/setup-required.html' %}
|
||||
{% include 'modals/confirm.html' %}
|
||||
{% include 'modals/add-device.html' %}
|
||||
{% include 'modals/capture-template.html' %}
|
||||
@@ -551,21 +552,132 @@
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
function submitApiKey(event) {
|
||||
// ─── Setup-required modal (shown when LAN client hits a server with
|
||||
// no auth.api_keys configured — no key will ever work) ───
|
||||
let _setupModalOpen = false;
|
||||
|
||||
function showSetupRequiredModal() {
|
||||
const modal = document.getElementById('setup-required-modal');
|
||||
if (!modal) return;
|
||||
// Update loopback link to match the current port (default 8080)
|
||||
try {
|
||||
const link = document.getElementById('setup-loopback-link');
|
||||
if (link) {
|
||||
const port = location.port || '8080';
|
||||
const href = `http://localhost:${port}`;
|
||||
link.setAttribute('href', href);
|
||||
link.textContent = href;
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
// Hide the api-key modal if it happened to be open
|
||||
const apiModal = document.getElementById('api-key-modal');
|
||||
if (apiModal) apiModal.style.display = 'none';
|
||||
modal.style.display = 'flex';
|
||||
_setupModalOpen = true;
|
||||
lockBody();
|
||||
// Tabs + login button make no sense while locked out
|
||||
const tabBar = document.querySelector('.tab-bar');
|
||||
if (tabBar) tabBar.style.display = 'none';
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
if (loginBtn) loginBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideSetupRequiredModal() {
|
||||
const modal = document.getElementById('setup-required-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
_setupModalOpen = false;
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
function copySetupSnippet() {
|
||||
const pre = document.getElementById('setup-yaml-snippet');
|
||||
if (!pre) return;
|
||||
const text = pre.textContent || '';
|
||||
const done = () => {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(t('setup.copied'), 'success');
|
||||
}
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, done);
|
||||
} else {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retrySetupCheck() {
|
||||
// Re-query /health. If setup_required is now false, reload so the
|
||||
// normal auth flow takes over.
|
||||
try {
|
||||
const resp = await fetch('/health', { signal: AbortSignal.timeout(5000) });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (!data.setup_required) {
|
||||
hideSetupRequiredModal();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore — will stay on the setup modal */ }
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(t('setup.still_required'), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApiKey(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const input = document.getElementById('api-key-input');
|
||||
const error = document.getElementById('api-key-error');
|
||||
const submitBtn = document.getElementById('api-key-submit');
|
||||
const key = input.value.trim();
|
||||
|
||||
error.style.display = 'none';
|
||||
|
||||
if (!key) {
|
||||
error.textContent = t('auth.error.required');
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate against server before accepting. Use a real auth-protected
|
||||
// endpoint so wrong keys return 401. /api/v1/system/api-keys is
|
||||
// cheap and requires AuthRequired.
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/v1/system/api-keys', {
|
||||
headers: { 'Authorization': `Bearer ${key}` }
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
let msg = t('auth.error.invalid');
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.detail) msg = body.detail;
|
||||
} catch { /* ignore parse errors */ }
|
||||
error.textContent = msg;
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (!resp.ok && resp.status !== 401) {
|
||||
// Server reachable but non-auth error — accept the key anyway
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error — accept key; user may be on a slow connection
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Store the key
|
||||
localStorage.setItem('ledgrab_api_key', key);
|
||||
if (window.setApiKey) window.setApiKey(key);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<option value="dmx">DMX</option>
|
||||
<option value="espnow">ESP-NOW</option>
|
||||
<option value="hue">Philips Hue</option>
|
||||
<option value="ble">BLE LED Controller</option>
|
||||
<option value="usbhid">USB HID</option>
|
||||
<option value="spi">SPI Direct</option>
|
||||
<option value="chroma">Razer Chroma</option>
|
||||
@@ -203,6 +204,30 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small>
|
||||
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
|
||||
</div>
|
||||
<!-- BLE LED Controller fields -->
|
||||
<div class="form-group" id="device-ble-family-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.ble.family.hint">Which BLE protocol your controller speaks. Match the phone app you normally use.</small>
|
||||
<select id="device-ble-family">
|
||||
<option value="sp110e">SP110E / SP108E</option>
|
||||
<option value="triones">Triones / HappyLighting / LEDnet</option>
|
||||
<option value="zengge">Zengge / iLightsIn</option>
|
||||
<option value="govee">Govee (experimental)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="device-ble-govee-key-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-ble-govee-key" data-i18n="device.ble.govee_key">Govee AES Key (hex):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.ble.govee_key.hint">Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.</small>
|
||||
<input type="text" id="device-ble-govee-key"
|
||||
data-i18n-placeholder="device.ble.govee_key.placeholder"
|
||||
placeholder="32 hex digits, e.g. 0102…1f20">
|
||||
</div>
|
||||
<!-- SPI Direct fields -->
|
||||
<div class="form-group" id="device-spi-speed-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button type="submit" class="btn btn-icon btn-primary" data-i18n-title="api_key.login" title="Login" data-i18n-aria-label="aria.save">✓</button>
|
||||
<button type="submit" id="api-key-submit" class="btn btn-icon btn-primary" data-i18n-title="api_key.login" title="Login" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- Setup Required Modal (shown when LAN client hits a server with no API keys configured) -->
|
||||
<div id="setup-required-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-required-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="setup-required-title">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12"/><circle cx="17" cy="7" r="5"/></svg>
|
||||
<span data-i18n="setup.title">Server setup required</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-description" data-i18n="setup.description">
|
||||
This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="setup.step1_label">1. On the server machine, edit <code>config/default_config.yaml</code>:</label>
|
||||
<div class="code-snippet-wrapper">
|
||||
<pre id="setup-yaml-snippet" class="code-snippet"><code>auth:
|
||||
api_keys:
|
||||
dev: "REPLACE_WITH_A_LONG_RANDOM_SECRET"</code></pre>
|
||||
<button type="button" class="btn btn-icon btn-secondary copy-btn" onclick="copySetupSnippet()" data-i18n-title="setup.copy" title="Copy">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<small class="input-hint" data-i18n="setup.hint_openssl">
|
||||
Generate a strong key on Linux/macOS with <code>openssl rand -hex 32</code>, or on Windows PowerShell with <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="setup.step2_label">2. Restart the server, then reload this page and log in with that key.</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="setup.step3_label">Alternative: open LedGrab from the server machine itself (loopback), no key required:</label>
|
||||
<div class="code-snippet-wrapper">
|
||||
<a id="setup-loopback-link" class="btn btn-secondary" href="http://localhost:8080" target="_blank" rel="noopener">http://localhost:8080</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-icon btn-secondary" onclick="retrySetupCheck()" data-i18n-title="setup.retry" title="Retry">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user