feat: Coordinator, Geraet und Licht-Entitaet

This commit is contained in:
Michael Sauer
2026-09-16 15:32:07 +02:00
parent 75dc2b7dd6
commit 40186d95d1
13 changed files with 1137 additions and 0 deletions
+64
View File
@@ -1 +1,65 @@
"""The ninlight integration."""
from __future__ import annotations
from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from .api import NinlightClient
from .const import DEFAULT_TOKEN, DOMAIN
from .coordinator import NinlightConfigEntry, NinlightCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
client = NinlightClient(
async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN]
)
coordinator = NinlightCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
_async_update_default_token_issue(hass, entry)
@callback
def _sync_firmware_version() -> None:
info = coordinator.data.info
registry = dr.async_get(hass)
device = registry.async_get_device_by_identifier(
(DOMAIN, format_mac(info["mac"])), entry.entry_id
)
if device is not None and device.sw_version != info["fw_version"]:
registry.async_update_device(device.id, sw_version=info["fw_version"])
entry.async_on_unload(coordinator.async_add_listener(_sync_firmware_version))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
ir.async_delete_issue(hass, DOMAIN, f"default_token_{entry.entry_id}")
@callback
def _async_update_default_token_issue(hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
issue_id = f"default_token_{entry.entry_id}"
if entry.data[CONF_TOKEN] == DEFAULT_TOKEN:
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="default_token",
translation_placeholders={"name": entry.title},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id)
+13
View File
@@ -0,0 +1,13 @@
"""Minimal config flow stub; Task 5 replaces this with the real flow."""
from __future__ import annotations
from homeassistant.config_entries import ConfigFlow
from .const import DOMAIN
class NinlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Placeholder so manifest.json's config_flow: true can be imported."""
VERSION = 1
+103
View File
@@ -0,0 +1,103 @@
"""Data coordinator for one ninlight lamp."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import (
NinlightAuthError,
NinlightBusyError,
NinlightClient,
NinlightError,
NinlightRequestError,
)
from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type NinlightConfigEntry = ConfigEntry[NinlightCoordinator]
@dataclass(frozen=True, slots=True)
class NinlightData:
"""Last known lamp state and system info."""
state: dict[str, Any]
info: dict[str, Any]
class NinlightCoordinator(DataUpdateCoordinator[NinlightData]):
"""Polls one lamp; every request to the lamp goes through `lock`."""
config_entry: NinlightConfigEntry
def __init__(
self, hass: HomeAssistant, entry: NinlightConfigEntry, client: NinlightClient
) -> None:
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} {client.host}",
update_interval=timedelta(
seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
),
)
self.client = client
# The W600 web server has a listen backlog of 4: never talk to one lamp concurrently.
self.lock = asyncio.Lock()
# While a firmware update runs, the update entity watches the lamp itself.
self.ota_in_progress = False
async def _async_update_data(self) -> NinlightData:
if self.ota_in_progress and self.data is not None:
return self.data
try:
async with self.lock:
state = await self.client.get_state()
info = await self.client.get_info()
except NinlightAuthError as err:
raise ConfigEntryAuthFailed(f"{self.client.host}: token rejected") from err
except NinlightError as err:
raise UpdateFailed(f"{self.client.host}: {err}") from err
return NinlightData(state, info)
async def async_call[T](self, action: Callable[[NinlightClient], Awaitable[T]]) -> T:
"""Run one request under the lock and translate lamp errors for the UI."""
try:
async with self.lock:
return await action(self.client)
except NinlightAuthError as err:
self.config_entry.async_start_reauth(self.hass)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="auth_failed"
) from err
except NinlightBusyError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="update_busy"
) from err
except NinlightRequestError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="request_failed",
translation_placeholders={"message": err.message},
) from err
except NinlightError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from err
async def async_set_state(self, **fields: Any) -> None:
"""Write and adopt the lamp's complete answer directly - no debounced refresh."""
state = await self.async_call(lambda client: client.set_state(**fields))
self.async_set_updated_data(NinlightData(state, self.data.info))
+43
View File
@@ -0,0 +1,43 @@
"""Base entity and device info for ninlight lamps."""
from __future__ import annotations
from typing import Any
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import NinlightCoordinator
def device_name(info: dict[str, Any]) -> str:
"""The lamp's own name, else '<product> <last four MAC digits>'."""
name = str(info.get("name") or "").strip()
if name:
return name
mac4 = format_mac(info["mac"]).replace(":", "")[-4:]
product = info.get("product_name") or info["device_type"]
return f"{product} {mac4}"
class NinlightEntity(CoordinatorEntity[NinlightCoordinator]):
"""Common base: unique id from the MAC, one device per lamp."""
_attr_has_entity_name = True
def __init__(self, coordinator: NinlightCoordinator, key: str) -> None:
super().__init__(coordinator)
info = coordinator.data.info
mac = format_mac(info["mac"])
self._attr_unique_id = f"{mac}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
manufacturer="ninlight",
model=info.get("product_name") or None,
model_id=info["device_type"],
name=device_name(info),
sw_version=info["fw_version"],
)
+126
View File
@@ -0,0 +1,126 @@
"""Light platform for ninlight lamps."""
from __future__ import annotations
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import MAX_TRANSITION_MS
from .coordinator import NinlightConfigEntry, NinlightCoordinator
from .entity import NinlightEntity
PARALLEL_UPDATES = 0 # the coordinator lock serializes per lamp
_FALLBACK_KELVIN = (2700, 6500)
def ha_to_lamp_brightness(value: int) -> int:
"""HA 1-255 -> lamp 1-100. Never 0: 0 would switch the lamp off."""
return max(1, min(100, round(value * 100 / 255)))
def lamp_to_ha_brightness(value: int) -> int:
"""Lamp 1-100 -> HA 1-255; round-trips back to the same lamp value."""
return max(1, min(255, round(value * 255 / 100)))
def transition_ms(seconds: float) -> int:
return max(0, min(MAX_TRANSITION_MS, round(seconds * 1000)))
async def async_setup_entry(
hass: HomeAssistant,
entry: NinlightConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
async_add_entities([NinlightLight(entry.runtime_data)])
class NinlightLight(NinlightEntity, LightEntity):
"""The lamp itself."""
_attr_name = None
_attr_supported_features = LightEntityFeature.TRANSITION
def __init__(self, coordinator: NinlightCoordinator) -> None:
super().__init__(coordinator, "light")
@property
def _state(self) -> dict[str, Any]:
return self.coordinator.data.state
@property
def is_on(self) -> bool:
return bool(self._state["on"])
@property
def brightness(self) -> int:
return lamp_to_ha_brightness(int(self._state["brightness"]))
@property
def supported_color_modes(self) -> set[ColorMode]:
if self._state["capabilities"].get("color"):
return {ColorMode.RGB, ColorMode.COLOR_TEMP}
return {ColorMode.COLOR_TEMP}
@property
def color_mode(self) -> ColorMode:
if self._state.get("mode") == "color" and ColorMode.RGB in self.supported_color_modes:
return ColorMode.RGB
return ColorMode.COLOR_TEMP
@property
def rgb_color(self) -> tuple[int, int, int] | None:
color = self._state.get("color")
if not isinstance(color, dict):
return None
return (int(color["r"]), int(color["g"]), int(color["b"]))
@property
def color_temp_kelvin(self) -> int | None:
value = self._state.get("color_temp")
return int(value) if value is not None else None
def _kelvin_range(self) -> tuple[int, int]:
value = self._state["capabilities"].get("color_temp")
if isinstance(value, list) and len(value) == 2:
return (int(value[0]), int(value[1]))
return _FALLBACK_KELVIN
@property
def min_color_temp_kelvin(self) -> int:
return self._kelvin_range()[0]
@property
def max_color_temp_kelvin(self) -> int:
return self._kelvin_range()[1]
async def async_turn_on(self, **kwargs: Any) -> None:
fields: dict[str, Any] = {"on": True}
if ATTR_BRIGHTNESS in kwargs:
fields["brightness"] = ha_to_lamp_brightness(kwargs[ATTR_BRIGHTNESS])
if ATTR_RGB_COLOR in kwargs:
red, green, blue = kwargs[ATTR_RGB_COLOR]
fields["color"] = {"r": red, "g": green, "b": blue}
if ATTR_COLOR_TEMP_KELVIN in kwargs:
fields["color_temp"] = int(kwargs[ATTR_COLOR_TEMP_KELVIN])
if ATTR_TRANSITION in kwargs:
fields["transition"] = transition_ms(kwargs[ATTR_TRANSITION])
await self.coordinator.async_set_state(**fields)
async def async_turn_off(self, **kwargs: Any) -> None:
fields: dict[str, Any] = {"on": False}
if ATTR_TRANSITION in kwargs:
fields["transition"] = transition_ms(kwargs[ATTR_TRANSITION])
await self.coordinator.async_set_state(**fields)
+106
View File
@@ -0,0 +1,106 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Set up ninlight lamp",
"description": "Enter the address of the lamp. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
"data": { "host": "Host", "token": "Token" },
"data_description": {
"host": "IP address or ninlight-xxxx.local name of the lamp.",
"token": "Authorization token of the lamp."
}
},
"zeroconf_confirm": {
"title": "Set up {name}",
"description": "Found the ninlight lamp {name} at {host}. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
"data": { "token": "Token" }
},
"reauth_confirm": {
"title": "Re-authenticate {name}",
"description": "The lamp rejected the stored token. Enter its current token.",
"data": { "token": "Token" }
},
"reconfigure": {
"title": "Reconfigure lamp",
"data": { "host": "Host", "token": "Token" }
}
},
"error": {
"cannot_connect": "Cannot reach the lamp.",
"invalid_auth": "The lamp rejected the token.",
"not_ninlight": "The device did not answer like a ninlight lamp.",
"unknown": "Unexpected error."
},
"abort": {
"already_configured": "This lamp is already set up.",
"not_ninlight": "The device did not announce itself like a ninlight lamp.",
"wrong_device": "This address belongs to a different lamp.",
"reauth_successful": "Re-authentication was successful.",
"reconfigure_successful": "Reconfiguration was successful."
}
},
"options": {
"step": {
"init": {
"title": "ninlight options",
"data": {
"scan_interval": "Polling interval (seconds)",
"lamp_base_url": "Address lamps use to reach Home Assistant"
},
"data_description": {
"lamp_base_url": "Only needed for firmware updates if lamps cannot reach Home Assistant's internal URL over plain http, e.g. http://192.168.1.50:8123"
}
}
},
"error": {
"invalid_base_url": "Must be a plain http:// address with host and optional port, without path.",
"base_url_too_long": "Address too long – the lamp accepts at most 255 characters for the whole firmware URL."
}
},
"entity": {
"number": {
"fade_in": { "name": "Fade-in" },
"fade_out": { "name": "Fade-out" },
"transition_default": { "name": "Default transition" }
},
"select": {
"power_on_behaviour": {
"name": "Power-on behaviour",
"state": { "last": "Last state", "on": "On", "off": "Off" }
}
},
"switch": {
"wake_on_change": { "name": "Turn on when changed" }
},
"sensor": {
"ip_address": { "name": "IP address" },
"free_heap": { "name": "Free memory" },
"last_ota_error": { "name": "Last update error" },
"boot_time": { "name": "Last start" }
}
},
"exceptions": {
"cannot_connect": { "message": "Cannot reach the lamp." },
"auth_failed": { "message": "The lamp rejected the token." },
"request_failed": { "message": "The lamp rejected the request: {message}" },
"update_busy": { "message": "An update is already running on the lamp." },
"no_update": { "message": "No firmware update is available." },
"no_http_url": { "message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options." },
"lamp_url_too_long": { "message": "The firmware URL is longer than the 255 characters the lamp accepts." },
"image_invalid": { "message": "The firmware file changed or is no longer valid." },
"wifi_guard": { "message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection." },
"update_failed": { "message": "The update failed: {reason}" },
"update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." }
},
"issues": {
"default_token": {
"title": "{name} uses the public default token",
"description": "Anyone on your network can control and reflash this lamp with the default token. Set your own token on the lamp and enter it via reconfigure."
},
"wifi_guard": {
"title": "Firmware for {name} not offered",
"description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The lamp only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered."
}
}
}
@@ -0,0 +1,106 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "ninlight-Lampe einrichten",
"description": "Adresse der Lampe eingeben. Das Token ist mit dem öffentlichen Vorgabe-Token vorbelegt – wer es kennt, kann die Lampe in deinem Netz steuern und neu flashen.",
"data": { "host": "Host", "token": "Token" },
"data_description": {
"host": "IP-Adresse oder ninlight-xxxx.local-Name der Lampe.",
"token": "Autorisierungs-Token der Lampe."
}
},
"zeroconf_confirm": {
"title": "{name} einrichten",
"description": "ninlight-Lampe {name} unter {host} gefunden. Das Token ist mit dem öffentlichen Vorgabe-Token vorbelegt – wer es kennt, kann die Lampe in deinem Netz steuern und neu flashen.",
"data": { "token": "Token" }
},
"reauth_confirm": {
"title": "{name} neu anmelden",
"description": "Die Lampe hat das gespeicherte Token abgelehnt. Gib ihr aktuelles Token ein.",
"data": { "token": "Token" }
},
"reconfigure": {
"title": "Lampe neu konfigurieren",
"data": { "host": "Host", "token": "Token" }
}
},
"error": {
"cannot_connect": "Die Lampe ist nicht erreichbar.",
"invalid_auth": "Die Lampe hat das Token abgelehnt.",
"not_ninlight": "Das Gerät antwortet nicht wie eine ninlight-Lampe.",
"unknown": "Unerwarteter Fehler."
},
"abort": {
"already_configured": "Diese Lampe ist bereits eingerichtet.",
"not_ninlight": "Das Gerät meldet sich nicht wie eine ninlight-Lampe.",
"wrong_device": "Unter dieser Adresse antwortet eine andere Lampe.",
"reauth_successful": "Neuanmeldung erfolgreich.",
"reconfigure_successful": "Neukonfiguration erfolgreich."
}
},
"options": {
"step": {
"init": {
"title": "ninlight-Optionen",
"data": {
"scan_interval": "Abfrageintervall (Sekunden)",
"lamp_base_url": "Adresse, unter der Lampen Home Assistant erreichen"
},
"data_description": {
"lamp_base_url": "Nur für Firmware-Updates nötig, wenn die Lampen die interne URL von Home Assistant nicht über reines http erreichen, z. B. http://192.168.1.50:8123"
}
}
},
"error": {
"invalid_base_url": "Muss eine reine http://-Adresse mit Host und optionalem Port sein, ohne Pfad.",
"base_url_too_long": "Adresse zu lang – die Lampe akzeptiert höchstens 255 Zeichen für die gesamte Firmware-URL."
}
},
"entity": {
"number": {
"fade_in": { "name": "Einblenden" },
"fade_out": { "name": "Ausblenden" },
"transition_default": { "name": "Standardübergang" }
},
"select": {
"power_on_behaviour": {
"name": "Einschaltverhalten",
"state": { "last": "Letzter Zustand", "on": "Ein", "off": "Aus" }
}
},
"switch": {
"wake_on_change": { "name": "Bei Änderung einschalten" }
},
"sensor": {
"ip_address": { "name": "IP-Adresse" },
"free_heap": { "name": "Freier Speicher" },
"last_ota_error": { "name": "Letzter Update-Fehler" },
"boot_time": { "name": "Letzter Start" }
}
},
"exceptions": {
"cannot_connect": { "message": "Die Lampe ist nicht erreichbar." },
"auth_failed": { "message": "Die Lampe hat das Token abgelehnt." },
"request_failed": { "message": "Die Lampe hat die Anfrage abgelehnt: {message}" },
"update_busy": { "message": "Auf der Lampe läuft bereits ein Update." },
"no_update": { "message": "Kein Firmware-Update verfügbar." },
"no_http_url": { "message": "Lampen erreichen Home Assistant nur über reines http. Setze die Adresse in den Optionen der Integration." },
"lamp_url_too_long": { "message": "Die Firmware-URL ist länger als die 255 Zeichen, die die Lampe akzeptiert." },
"image_invalid": { "message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig." },
"wifi_guard": { "message": "Diese Firmware enthält keine WLAN-Zugangsdaten für {ssid}; die Lampe wäre danach nicht mehr im Netz." },
"update_failed": { "message": "Das Update ist fehlgeschlagen: {reason}" },
"update_timeout": { "message": "Die Lampe hat die neue Firmware nicht innerhalb von 12 Minuten gemeldet." }
},
"issues": {
"default_token": {
"title": "{name} nutzt das öffentliche Vorgabe-Token",
"description": "Mit dem Vorgabe-Token kann jeder in deinem Netz diese Lampe steuern und neu flashen. Setze ein eigenes Token auf der Lampe und trage es über „Neu konfigurieren“ ein."
},
"wifi_guard": {
"title": "Firmware für {name} nicht angeboten",
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Die Lampe kennt ihr WLAN nur aus ihrer Firmware und wäre nach diesem Update nicht mehr im Netz – das Update wird deshalb nicht angeboten."
}
}
}
@@ -0,0 +1,106 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Set up ninlight lamp",
"description": "Enter the address of the lamp. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
"data": { "host": "Host", "token": "Token" },
"data_description": {
"host": "IP address or ninlight-xxxx.local name of the lamp.",
"token": "Authorization token of the lamp."
}
},
"zeroconf_confirm": {
"title": "Set up {name}",
"description": "Found the ninlight lamp {name} at {host}. The token is prefilled with the public default token – anyone on your network who knows it can control and reflash the lamp.",
"data": { "token": "Token" }
},
"reauth_confirm": {
"title": "Re-authenticate {name}",
"description": "The lamp rejected the stored token. Enter its current token.",
"data": { "token": "Token" }
},
"reconfigure": {
"title": "Reconfigure lamp",
"data": { "host": "Host", "token": "Token" }
}
},
"error": {
"cannot_connect": "Cannot reach the lamp.",
"invalid_auth": "The lamp rejected the token.",
"not_ninlight": "The device did not answer like a ninlight lamp.",
"unknown": "Unexpected error."
},
"abort": {
"already_configured": "This lamp is already set up.",
"not_ninlight": "The device did not announce itself like a ninlight lamp.",
"wrong_device": "This address belongs to a different lamp.",
"reauth_successful": "Re-authentication was successful.",
"reconfigure_successful": "Reconfiguration was successful."
}
},
"options": {
"step": {
"init": {
"title": "ninlight options",
"data": {
"scan_interval": "Polling interval (seconds)",
"lamp_base_url": "Address lamps use to reach Home Assistant"
},
"data_description": {
"lamp_base_url": "Only needed for firmware updates if lamps cannot reach Home Assistant's internal URL over plain http, e.g. http://192.168.1.50:8123"
}
}
},
"error": {
"invalid_base_url": "Must be a plain http:// address with host and optional port, without path.",
"base_url_too_long": "Address too long – the lamp accepts at most 255 characters for the whole firmware URL."
}
},
"entity": {
"number": {
"fade_in": { "name": "Fade-in" },
"fade_out": { "name": "Fade-out" },
"transition_default": { "name": "Default transition" }
},
"select": {
"power_on_behaviour": {
"name": "Power-on behaviour",
"state": { "last": "Last state", "on": "On", "off": "Off" }
}
},
"switch": {
"wake_on_change": { "name": "Turn on when changed" }
},
"sensor": {
"ip_address": { "name": "IP address" },
"free_heap": { "name": "Free memory" },
"last_ota_error": { "name": "Last update error" },
"boot_time": { "name": "Last start" }
}
},
"exceptions": {
"cannot_connect": { "message": "Cannot reach the lamp." },
"auth_failed": { "message": "The lamp rejected the token." },
"request_failed": { "message": "The lamp rejected the request: {message}" },
"update_busy": { "message": "An update is already running on the lamp." },
"no_update": { "message": "No firmware update is available." },
"no_http_url": { "message": "Lamps can only reach Home Assistant over plain http. Set the address in the integration options." },
"lamp_url_too_long": { "message": "The firmware URL is longer than the 255 characters the lamp accepts." },
"image_invalid": { "message": "The firmware file changed or is no longer valid." },
"wifi_guard": { "message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the lamp would lose its network connection." },
"update_failed": { "message": "The update failed: {reason}" },
"update_timeout": { "message": "The lamp did not report the new firmware within 12 minutes." }
},
"issues": {
"default_token": {
"title": "{name} uses the public default token",
"description": "Anyone on your network can control and reflash this lamp with the default token. Set your own token on the lamp and enter it via reconfigure."
},
"wifi_guard": {
"title": "Firmware for {name} not offered",
"description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The lamp only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered."
}
}
}