From 2e253eff30bf8cfb8d5f90e5b325985c158404e4 Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 22:43:33 +0200 Subject: [PATCH 1/8] Add Shelly Power Strip (Gen4) support - api.py: auto-detect the LED RPC component (PLUGS_UI vs POWERSTRIP_UI) so the same client works on single-outlet plugs and the 4-outlet Power Strip. - light.py: create one LED light entity per outlet, discovered from the switch:N keys in the device's LED color config, instead of a single hardcoded switch:0 entity. Single-outlet devices keep their original entity name/unique_id for backward compatibility. - config_flow.py: broaden the device picker filter to also include power strip models, not just plugs. - button.py: generalize the reset button label since it now resets LEDs across multiple outlets on a Power Strip. - Update translations (en/de) and README for multi-outlet wording, and note the firmware limitation that LED mode is shared across all outlets while color/brightness stays per-outlet. - Bump manifest version to 1.2.0. --- README.md | 14 ++- custom_components/shelly_plug_led/api.py | 33 ++++++- custom_components/shelly_plug_led/button.py | 8 +- .../shelly_plug_led/config_flow.py | 7 +- custom_components/shelly_plug_led/light.py | 91 +++++++++++++++---- .../shelly_plug_led/manifest.json | 2 +- .../shelly_plug_led/translations/de.json | 12 +-- .../shelly_plug_led/translations/en.json | 12 +-- 8 files changed, 139 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 20b6325..86a5d1d 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ # Shelly Plug LED Ring Integration for Home Assistant -A custom Home Assistant integration that turns the built-in RGB LED ring of your **Shelly Plug S (Gen2 / Gen3)** devices into an independent, fully controllable smart light entity. +A custom Home Assistant integration that turns the built-in RGB LED(s) of your **Shelly Plug S (Gen2 / Gen3)** or **Shelly Power Strip (Gen4)** devices into independent, fully controllable smart light entities. This integration interacts with the LED configuration engine. It allows you to - change colors - apply dimming levels -- toggle the ring on and off +- toggle the LED(s) on and off -**without affecting the operational on/off power state of the actual smart plug relay**. +**without affecting the operational on/off power state of the actual smart plug/outlet relay(s)**.

Alt text

## Prerequisites -1. You must have your Shelly plugs already configured and active in Home Assistant via the **official built-in Shelly integration**. -2. Your hardware must be Generation 2 or Generation 3 local RPC devices (such as the standard Shelly Plus Plug S or newer variants). +1. You must have your Shelly plug(s) or power strip already configured and active in Home Assistant via the **official built-in Shelly integration**. +2. Your hardware must be a Generation 2/3 local RPC plug (such as the standard Shelly Plus Plug S or newer variants) or a Generation 4 Shelly Power Strip. + +## Multi-outlet devices (Shelly Power Strip) + +On a Power Strip, one `LED Outlet N` light entity is created per physical outlet (`switch:0`..`switch:3`), each with its own independently controllable color and brightness. There's a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by all outlets, not per-outlet - so turning any one outlet's LED off switches the whole strip's LED mode off, and all outlet LEDs will show as off. Only the *color* is independent per outlet while the strip is in "switch" mode. The "Reset LEDs to Default" button likewise resets the whole device, not a single outlet. --- diff --git a/custom_components/shelly_plug_led/api.py b/custom_components/shelly_plug_led/api.py index 9d85017..01c7725 100644 --- a/custom_components/shelly_plug_led/api.py +++ b/custom_components/shelly_plug_led/api.py @@ -26,6 +26,13 @@ DEFAULT_USERNAME = "admin" TIMEOUT = 5 +# RPC components that expose LED configuration, tried in this order until one +# answers. ``PLUGS_UI`` covers single-outlet devices (Shelly Plug S Gen2/Gen3, +# ...); ``POWERSTRIP_UI`` covers multi-outlet devices (Shelly Power Strip +# Gen4, with switch:0..switch:3). Both share the same ``leds.colors`` / +# ``leds.mode`` config shape, just keyed by a different set of RPC methods. +LED_UI_COMPONENTS = ("PLUGS_UI", "POWERSTRIP_UI") + class ShellyAuthError(Exception): """Raised when the device requires auth we cannot satisfy (401, no/invalid creds).""" @@ -81,6 +88,7 @@ def __init__( self._username = username or DEFAULT_USERNAME self._password = password self._url = f"http://{host}/rpc" + self._ui_component: str | None = None def set_credentials(self, username: str | None, password: str | None) -> None: """Update credentials at runtime without rebuilding the client.""" @@ -152,10 +160,31 @@ async def call(self, method: str, params: dict | None = None) -> dict: return await self._handle(res) async def get_config(self) -> dict: - return await self.call("PLUGS_UI.GetConfig") + """Fetch the LED config, auto-detecting which RPC component the device exposes. + + Probes ``LED_UI_COMPONENTS`` in order the first time and remembers the + one that answered, so later calls (and ``set_config``) go straight to it. + """ + if self._ui_component: + return await self.call(f"{self._ui_component}.GetConfig") + + last_err: Exception | None = None + for component in LED_UI_COMPONENTS: + try: + result = await self.call(f"{component}.GetConfig") + except ShellyAuthError: + raise # Conclusive - not a "wrong component" signal. + except Exception as err: # noqa: BLE001 - probing; any failure means "try next" + last_err = err + continue + self._ui_component = component + return result + raise last_err or RuntimeError("Device exposes no supported LED UI component") async def set_config(self, config: dict) -> dict: - return await self.call("PLUGS_UI.SetConfig", {"config": config}) + if not self._ui_component: + await self.get_config() # Probe for the right component first. + return await self.call(f"{self._ui_component}.SetConfig", {"config": config}) def find_shelly_entry(hass, host: str): diff --git a/custom_components/shelly_plug_led/button.py b/custom_components/shelly_plug_led/button.py index 8605736..07b2ae9 100644 --- a/custom_components/shelly_plug_led/button.py +++ b/custom_components/shelly_plug_led/button.py @@ -23,11 +23,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e ]) class ShellyPlugLedResetButton(CoordinatorEntity, ButtonEntity): - """Button to reset the Shelly Plug LED Ring back to its out-of-the-box factory configuration.""" + """Button to reset the Shelly LED(s) back to their out-of-the-box factory configuration. + + LED mode is a single firmware-wide setting (see ShellyPlugLedRing.is_on), + so one button resets all outlets on multi-outlet devices too. + """ _attr_has_entity_name = True _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Reset LED Ring to Default" + _attr_name = "Reset LEDs to Default" _attr_icon = "mdi:restore" def __init__(self, coordinator, client, host, entry_id, identifiers): diff --git a/custom_components/shelly_plug_led/config_flow.py b/custom_components/shelly_plug_led/config_flow.py index fde5603..736408c 100644 --- a/custom_components/shelly_plug_led/config_flow.py +++ b/custom_components/shelly_plug_led/config_flow.py @@ -42,9 +42,12 @@ async def async_step_user(self, user_input=None): device = entry_devices[0] - # Filter: Only show devices that contain "plug" in their model type + # Filter: only show devices whose LED subsystem we know how to + # drive - single-outlet plugs (PLUGS_UI) and multi-outlet power + # strips (POWERSTRIP_UI, e.g. "Shelly Power Strip 4 Gen4"). model = device.model or "" - if "plug" not in model.lower(): + model_lower = model.lower() + if not any(keyword in model_lower for keyword in ("plug", "power strip", "powerstrip")): continue # Discover which room the plug is currently assigned to diff --git a/custom_components/shelly_plug_led/light.py b/custom_components/shelly_plug_led/light.py index e878e73..c9663d4 100644 --- a/custom_components/shelly_plug_led/light.py +++ b/custom_components/shelly_plug_led/light.py @@ -1,5 +1,6 @@ import asyncio import logging +import re from typing import Any from homeassistant.components.light import ColorMode, LightEntity @@ -13,32 +14,82 @@ DOMAIN = "shelly_plug_led" _LOGGER = logging.getLogger(__name__) +# Matches the per-outlet keys Shelly uses inside ``leds.colors`` +# (e.g. "switch:0" .. "switch:3" on a Power Strip). Other keys such as +# "power" (the power-tracking-mode brightness) are ignored. +_SWITCH_KEY_RE = re.compile(r"^switch:(\d+)$") + + +def _discover_switch_keys(coordinator_data: dict | None) -> list[str]: + """Return the sorted ``switch:N`` keys present in the LED color config.""" + colors = (coordinator_data or {}).get("leds", {}).get("colors", {}) + keys = [key for key in colors if _SWITCH_KEY_RE.match(key)] + keys.sort(key=lambda key: int(_SWITCH_KEY_RE.match(key).group(1))) + return keys or ["switch:0"] # Fall back to the single-outlet default. + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: - """Set up the light platform using configuration entry details.""" + """Set up the light platform using configuration entry details. + + Creates one LED entity per outlet. Single-outlet devices (Shelly Plug S) + keep the original "LED Ring" name/unique_id; multi-outlet devices (Shelly + Power Strip) get one "LED Outlet N" entity per switch channel. + """ data = hass.data[DOMAIN][entry.entry_id] - async_add_entities([ - ShellyPlugLedRing( - coordinator=data["coordinator"], - client=data["client"], - host=data["host"], - entry_id=entry.entry_id, - identifiers=entry.data.get("identifiers", []) + coordinator = data["coordinator"] + switch_keys = _discover_switch_keys(coordinator.data) + multi = len(switch_keys) > 1 + + entities = [] + for switch_key in switch_keys: + index = int(_SWITCH_KEY_RE.match(switch_key).group(1)) + if multi: + name = f"LED Outlet {index + 1}" + unique_suffix = f"led_{switch_key.replace(':', '_')}" + else: + # Keep the original name/unique_id so existing single-outlet + # installs don't get a new entity_id after an update. + name = "LED Ring" + unique_suffix = "led_ring" + + entities.append( + ShellyPlugLedRing( + coordinator=coordinator, + client=data["client"], + host=data["host"], + entry_id=entry.entry_id, + identifiers=entry.data.get("identifiers", []), + switch_key=switch_key, + name=name, + unique_suffix=unique_suffix, + ) ) - ]) + async_add_entities(entities) class ShellyPlugLedRing(CoordinatorEntity, LightEntity): - """Representation of the Shelly Plug LED Ring with Optimistic State Management.""" + """Representation of a single Shelly LED (ring, or one outlet's indicator) with Optimistic State Management.""" _attr_has_entity_name = True _attr_color_mode = ColorMode.RGB _attr_supported_color_modes = {ColorMode.RGB} - def __init__(self, coordinator, client, host, entry_id, identifiers): + def __init__( + self, + coordinator, + client, + host, + entry_id, + identifiers, + switch_key: str = "switch:0", + name: str = "LED Ring", + unique_suffix: str = "led_ring", + ): super().__init__(coordinator) self._client = client self._host = host - self._attr_unique_id = f"{entry_id}_led_ring" - self._attr_name = "LED Ring" + self._switch_key = switch_key + self._attr_unique_id = f"{entry_id}_{unique_suffix}" + self._attr_name = name self._identifiers = identifiers self._attr_icon = "mdi:led-on" @@ -60,6 +111,14 @@ def led_config(self): @property def is_on(self) -> bool: + """Whether this LED's subsystem is in "switch" mode. + + Note: on multi-outlet devices (e.g. Shelly Power Strip) ``mode`` is a + single firmware-wide setting shared by all outlets - turning any one + outlet's LED off disables "switch" mode for the whole device. Only + the RGB color/brightness below is independently addressable per + outlet. + """ if self._optimistic_is_on is not None: return self._optimistic_is_on return self.led_config.get("mode") == "switch" @@ -68,14 +127,14 @@ def is_on(self) -> bool: def brightness(self) -> int: if self._optimistic_brightness is not None: return self._optimistic_brightness - b = self.led_config.get("colors", {}).get("switch:0", {}).get("on", {}).get("brightness", 100) + b = self.led_config.get("colors", {}).get(self._switch_key, {}).get("on", {}).get("brightness", 100) return round((b / 100) * 255) @property def rgb_color(self) -> tuple[int, int, int]: if self._optimistic_rgb is not None: return self._optimistic_rgb - rgb = self.led_config.get("colors", {}).get("switch:0", {}).get("on", {}).get("rgb", [100, 100, 100]) + rgb = self.led_config.get("colors", {}).get(self._switch_key, {}).get("on", {}).get("rgb", [100, 100, 100]) return (round((rgb[0]/100)*255), round((rgb[1]/100)*255), round((rgb[2]/100)*255)) @callback @@ -118,7 +177,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: "leds": { "mode": "switch", "colors": { - "switch:0": { + self._switch_key: { "on": {"rgb": [r, g, b], "brightness": pct_b}, "off": {"rgb": [r, g, b], "brightness": pct_b} } diff --git a/custom_components/shelly_plug_led/manifest.json b/custom_components/shelly_plug_led/manifest.json index b862ef8..9aee22e 100644 --- a/custom_components/shelly_plug_led/manifest.json +++ b/custom_components/shelly_plug_led/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/ishiharas/shelly_plug_led/issues", "requirements": [], - "version": "1.1.0" + "version": "1.2.0" } \ No newline at end of file diff --git a/custom_components/shelly_plug_led/translations/de.json b/custom_components/shelly_plug_led/translations/de.json index d2f6131..5bc9d7b 100644 --- a/custom_components/shelly_plug_led/translations/de.json +++ b/custom_components/shelly_plug_led/translations/de.json @@ -2,14 +2,14 @@ "config": { "step": { "user": { - "description": "Bitte wählen Sie einen Ihrer vorhandenen Shelly Plugs aus dem Dropdown-Menü aus, um dessen LED-Ring zu steuern. Das Gerät muss vorab über die offizielle Shelly-Integration eingerichtet worden sein, damit es hier zur Auswahl steht.", + "description": "Bitte wählen Sie einen Ihrer vorhandenen Shelly Plugs oder Steckerleisten aus dem Dropdown-Menü aus, um dessen/deren LED(s) zu steuern. Das Gerät muss vorab über die offizielle Shelly-Integration eingerichtet worden sein, damit es hier zur Auswahl steht.", "data": { - "shelly_device": "Shelly Plug auswählen:" + "shelly_device": "Shelly-Gerät auswählen:" } }, "reauth_confirm": { - "title": "Shelly Plug LED Authentifizierung", - "description": "Die Authentifizierung für den Shelly Plug unter {host} ist fehlgeschlagen. Geben Sie das Gerätepasswort ein, um die Verbindung wiederherzustellen. Der Benutzername ist normalerweise \"admin\".", + "title": "Shelly LED Authentifizierung", + "description": "Die Authentifizierung für das Shelly-Gerät unter {host} ist fehlgeschlagen. Geben Sie das Gerätepasswort ein, um die Verbindung wiederherzustellen. Der Benutzername ist normalerweise \"admin\".", "data": { "username": "Benutzername", "password": "Passwort" @@ -21,8 +21,8 @@ "cannot_connect": "Verbindung zum Gerät fehlgeschlagen." }, "abort": { - "no_shelly_plugs_found": "Keine kompatiblen Shelly Plugs gefunden oder alle vorhandenen Plugs wurden bereits konfiguriert.", - "already_configured": "Diese Shelly Plug LED-Integration ist für dieses Gerät bereits konfiguriert.", + "no_shelly_plugs_found": "Keine kompatiblen Shelly Plugs oder Steckerleisten gefunden, oder alle vorhandenen Geräte wurden bereits konfiguriert.", + "already_configured": "Diese Shelly LED-Integration ist für dieses Gerät bereits konfiguriert.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich." } } diff --git a/custom_components/shelly_plug_led/translations/en.json b/custom_components/shelly_plug_led/translations/en.json index cbe73b1..a8b88e7 100644 --- a/custom_components/shelly_plug_led/translations/en.json +++ b/custom_components/shelly_plug_led/translations/en.json @@ -2,14 +2,14 @@ "config": { "step": { "user": { - "description": "Please select one of your existing Shelly plugs from the dropdown menu below to control its LED ring. Note: The device must already be set up through the official Shelly integration before it will appear here.", + "description": "Please select one of your existing Shelly plugs or power strips from the dropdown menu below to control its LED(s). Note: The device must already be set up through the official Shelly integration before it will appear here.", "data": { - "shelly_device": "Select Shelly Plug:" + "shelly_device": "Select Shelly device:" } }, "reauth_confirm": { - "title": "Shelly Plug LED authentication", - "description": "Authentication failed for the Shelly Plug at {host}. Enter the device password to reconnect. The username is usually \"admin\".", + "title": "Shelly LED authentication", + "description": "Authentication failed for the Shelly device at {host}. Enter the device password to reconnect. The username is usually \"admin\".", "data": { "username": "Username", "password": "Password" @@ -21,8 +21,8 @@ "cannot_connect": "Failed to connect to the device." }, "abort": { - "no_shelly_plugs_found": "No compatible Shelly Plugs found, or all of your existing plugs have already been configured.", - "already_configured": "This Shelly Plug LED integration is already configured for this device.", + "no_shelly_plugs_found": "No compatible Shelly plugs or power strips found, or all of your existing devices have already been configured.", + "already_configured": "This Shelly LED integration is already configured for this device.", "reauth_successful": "Re-authentication was successful." } } From 2e151ca88808bb92367c2fc202126489c9d802c3 Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 22:54:16 +0200 Subject: [PATCH 2/8] Fix LED UI component detection locking onto PLUGS_UI on Power Strip On a real Shelly Power Strip Gen4, PLUGS_UI.GetConfig also answers (with only switch:0) alongside POWERSTRIP_UI.GetConfig (with all 4 channels). The previous 'first success wins' probe locked onto PLUGS_UI, so only a single LED Ring entity was created instead of one per outlet. Now all LED_UI_COMPONENTS are probed and the one whose config reports the most switch:N keys is kept and cached. Also: - manifest.json: point documentation/issue_tracker at this fork, credit original creator @ishiharas alongside @radioactive-bbs in codeowners. - README.md: GitHub badge linking to this fork, Credits section for the original creator. --- README.md | 12 +++++++++ custom_components/shelly_plug_led/api.py | 27 +++++++++++++++---- .../shelly_plug_led/manifest.json | 6 ++--- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 86a5d1d..b4f141d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Shelly Plug LED Ring Integration for Home Assistant +

+ GitHub repository +

+ +> This is a fork of the original **[shelly_plug_led](https://github.com/ishiharas/shelly_plug_led)** by **[@ishiharas](https://github.com/ishiharas)** — all credit for the original design and implementation goes to them. This fork adds **Shelly Power Strip (Gen4)** support on top of it. + A custom Home Assistant integration that turns the built-in RGB LED(s) of your **Shelly Plug S (Gen2 / Gen3)** or **Shelly Power Strip (Gen4)** devices into independent, fully controllable smart light entities. This integration interacts with the LED configuration engine. It allows you to @@ -45,3 +51,9 @@ On a Power Strip, one `LED Outlet N` light entity is created per physical outlet 1. In Home Assistant, navigate to **Settings > Devices & Services**. 2. Click the **Add Integration** button in the bottom right corner. 3. Search for **Shelly Plug LED Ring** and select it. + +--- + +## Credits + +Originally created by **[@ishiharas](https://github.com/ishiharas)** — see the upstream project at [ishiharas/shelly_plug_led](https://github.com/ishiharas/shelly_plug_led). This fork ([@radioactive-bbs](https://github.com/radioactive-bbs)) builds on that work to add Shelly Power Strip (Gen4) multi-outlet support. diff --git a/custom_components/shelly_plug_led/api.py b/custom_components/shelly_plug_led/api.py index 01c7725..de8c011 100644 --- a/custom_components/shelly_plug_led/api.py +++ b/custom_components/shelly_plug_led/api.py @@ -159,15 +159,27 @@ async def call(self, method: str, params: dict | None = None) -> dict: ) return await self._handle(res) + @staticmethod + def _switch_count(result: dict) -> int: + """Count switch:N keys in a GetConfig result's leds.colors.""" + colors = result.get("leds", {}).get("colors", {}) + return sum(1 for key in colors if key.startswith("switch:")) + async def get_config(self) -> dict: """Fetch the LED config, auto-detecting which RPC component the device exposes. - Probes ``LED_UI_COMPONENTS`` in order the first time and remembers the - one that answered, so later calls (and ``set_config``) go straight to it. + Some devices (e.g. Power Strip Gen4) answer *both* PLUGS_UI and + POWERSTRIP_UI - PLUGS_UI apparently as a single-outlet compatibility + shim that only reports switch:0. Stopping at the first successful + component would silently lock onto that shim on a multi-outlet + device, so every component is probed and the one whose config + reports the most outlets (switch:N keys) is kept. The winner is + cached, so this only costs the extra round-trip once. """ if self._ui_component: return await self.call(f"{self._ui_component}.GetConfig") + candidates: list[tuple[str, dict]] = [] last_err: Exception | None = None for component in LED_UI_COMPONENTS: try: @@ -177,9 +189,14 @@ async def get_config(self) -> dict: except Exception as err: # noqa: BLE001 - probing; any failure means "try next" last_err = err continue - self._ui_component = component - return result - raise last_err or RuntimeError("Device exposes no supported LED UI component") + candidates.append((component, result)) + + if not candidates: + raise last_err or RuntimeError("Device exposes no supported LED UI component") + + component, result = max(candidates, key=lambda item: self._switch_count(item[1])) + self._ui_component = component + return result async def set_config(self, config: dict) -> dict: if not self._ui_component: diff --git a/custom_components/shelly_plug_led/manifest.json b/custom_components/shelly_plug_led/manifest.json index 9aee22e..4172863 100644 --- a/custom_components/shelly_plug_led/manifest.json +++ b/custom_components/shelly_plug_led/manifest.json @@ -1,11 +1,11 @@ { "domain": "shelly_plug_led", "name": "Shelly Plug LED Ring", - "codeowners": ["@ishiharas"], + "codeowners": ["@radioactive-bbs", "@ishiharas"], "config_flow": true, - "documentation": "https://github.com/ishiharas/shelly_plug_led", + "documentation": "https://github.com/radioactive-bbs/shelly_plug_led", "iot_class": "local_polling", - "issue_tracker": "https://github.com/ishiharas/shelly_plug_led/issues", + "issue_tracker": "https://github.com/radioactive-bbs/shelly_plug_led/issues", "requirements": [], "version": "1.2.0" } \ No newline at end of file From a3227a977d991b607effa342a5dfe41e19426835 Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 23:05:48 +0200 Subject: [PATCH 3/8] Expose independent on-color and off-color LED entities Real Power Strip Gen4 firmware confirmed: each outlet's physical LED independently tracks its own relay state (native switch mode), but the config only exposes ONE on/off color pair per LED - and the previous light entity wrote the same RGB to both 'on' and 'off' on every turn_on, silently clobbering any asymmetric on/off color scheme (e.g. red-when-on / green-when-off) the moment the entity was touched again. light.py: split ShellyPlugLedRing by a new color_key ('on'/'off') so each LED gets two entities, e.g. 'LED Ring' + 'LED Ring Off Color', each writing only its own color slot. The 'on' entity keeps its original name/unique_id for backward compatibility; only a new 'Off Color' entity is added. README: document the on/off split and that a Power Strip's outlets currently share one color slot in firmware while still tracking their own relay state independently - so per-outlet colors work without any HA automation once set. Bump manifest version to 1.3.0. --- README.md | 6 +- custom_components/shelly_plug_led/light.py | 66 +++++++++++++------ .../shelly_plug_led/manifest.json | 2 +- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b4f141d..f6b12bc 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,13 @@ This integration interacts with the LED configuration engine. It allows you to 1. You must have your Shelly plug(s) or power strip already configured and active in Home Assistant via the **official built-in Shelly integration**. 2. Your hardware must be a Generation 2/3 local RPC plug (such as the standard Shelly Plus Plug S or newer variants) or a Generation 4 Shelly Power Strip. +## On-color vs. off-color + +Each LED gets **two** light entities: e.g. `LED Ring` (the color shown while the relay is **on**) and `LED Ring Off Color` (shown while it's **off**). This mirrors the device's native "switch" LED mode, which already tracks that relay's own on/off state in firmware - so e.g. red-when-on / green-when-off needs no automation: set both colors once and the device handles the rest, even while Home Assistant is offline. Turning either entity off disables "switch" mode for the whole device (see below); turning it back on only ever rewrites its own color slot. + ## Multi-outlet devices (Shelly Power Strip) -On a Power Strip, one `LED Outlet N` light entity is created per physical outlet (`switch:0`..`switch:3`), each with its own independently controllable color and brightness. There's a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by all outlets, not per-outlet - so turning any one outlet's LED off switches the whole strip's LED mode off, and all outlet LEDs will show as off. Only the *color* is independent per outlet while the strip is in "switch" mode. The "Reset LEDs to Default" button likewise resets the whole device, not a single outlet. +On a Power Strip, one `LED Outlet N` / `LED Outlet N Off Color` pair is created per physical outlet found in the device's LED config (`switch:0`..`switch:3`). Note that on current Power Strip Gen4 firmware, the device actually reports only a single shared color slot (`switch:0`) for the whole strip rather than one per outlet - each outlet's physical LED still tracks *its own* relay state using that shared on/off color pair, so per-outlet red/green still works correctly, it's just configured once for the whole strip rather than per outlet. There's also a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by everything on the device - turning any one LED entity off switches LED mode off for the entire device. The "Reset LEDs to Default" button likewise resets the whole device, not a single outlet or color slot. --- diff --git a/custom_components/shelly_plug_led/light.py b/custom_components/shelly_plug_led/light.py index c9663d4..ec0e72f 100644 --- a/custom_components/shelly_plug_led/light.py +++ b/custom_components/shelly_plug_led/light.py @@ -31,9 +31,12 @@ def _discover_switch_keys(coordinator_data: dict | None) -> list[str]: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: """Set up the light platform using configuration entry details. - Creates one LED entity per outlet. Single-outlet devices (Shelly Plug S) - keep the original "LED Ring" name/unique_id; multi-outlet devices (Shelly - Power Strip) get one "LED Outlet N" entity per switch channel. + Creates two LED entities per outlet: one for the "on" color, one for the + "off" color - matching the device's native switch-mode LED, which shows a + different color depending on that outlet's own relay state. Single-outlet + devices (Shelly Plug S) keep the original "LED Ring" name/unique_id for + the on-color entity; multi-outlet devices (Shelly Power Strip) get one + "LED Outlet N" pair per switch channel. """ data = hass.data[DOMAIN][entry.entry_id] coordinator = data["coordinator"] @@ -44,13 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e for switch_key in switch_keys: index = int(_SWITCH_KEY_RE.match(switch_key).group(1)) if multi: - name = f"LED Outlet {index + 1}" - unique_suffix = f"led_{switch_key.replace(':', '_')}" + base_name = f"LED Outlet {index + 1}" + base_suffix = f"led_{switch_key.replace(':', '_')}" else: # Keep the original name/unique_id so existing single-outlet # installs don't get a new entity_id after an update. - name = "LED Ring" - unique_suffix = "led_ring" + base_name = "LED Ring" + base_suffix = "led_ring" entities.append( ShellyPlugLedRing( @@ -60,14 +63,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e entry_id=entry.entry_id, identifiers=entry.data.get("identifiers", []), switch_key=switch_key, - name=name, - unique_suffix=unique_suffix, + color_key="on", + name=base_name, + unique_suffix=base_suffix, + ) + ) + entities.append( + ShellyPlugLedRing( + coordinator=coordinator, + client=data["client"], + host=data["host"], + entry_id=entry.entry_id, + identifiers=entry.data.get("identifiers", []), + switch_key=switch_key, + color_key="off", + name=f"{base_name} Off Color", + unique_suffix=f"{base_suffix}_off", ) ) async_add_entities(entities) class ShellyPlugLedRing(CoordinatorEntity, LightEntity): - """Representation of a single Shelly LED (ring, or one outlet's indicator) with Optimistic State Management.""" + """Representation of one color slot of a Shelly LED, with Optimistic State Management. + + The device's native "switch" LED mode holds two colors per outlet - one + shown while the outlet's relay is on, one while it's off - so each + outlet gets two of these entities (``color_key="on"`` / ``"off"``), each + writing only its own slot. This lets e.g. red-when-on/green-when-off be + configured entirely in firmware: once set, the LED tracks that outlet's + own relay state with no HA automation involved. + """ _attr_has_entity_name = True _attr_color_mode = ColorMode.RGB @@ -81,6 +106,7 @@ def __init__( entry_id, identifiers, switch_key: str = "switch:0", + color_key: str = "on", name: str = "LED Ring", unique_suffix: str = "led_ring", ): @@ -88,10 +114,11 @@ def __init__( self._client = client self._host = host self._switch_key = switch_key + self._color_key = color_key # "on" or "off" - which color slot this entity writes. self._attr_unique_id = f"{entry_id}_{unique_suffix}" self._attr_name = name self._identifiers = identifiers - self._attr_icon = "mdi:led-on" + self._attr_icon = "mdi:led-on" if color_key == "on" else "mdi:led-variant-outline" self._optimistic_is_on = None self._optimistic_brightness = None @@ -113,11 +140,11 @@ def led_config(self): def is_on(self) -> bool: """Whether this LED's subsystem is in "switch" mode. - Note: on multi-outlet devices (e.g. Shelly Power Strip) ``mode`` is a - single firmware-wide setting shared by all outlets - turning any one - outlet's LED off disables "switch" mode for the whole device. Only - the RGB color/brightness below is independently addressable per - outlet. + Note: ``mode`` is a single firmware-wide setting shared by every + color-slot entity on the device (both on/off entities, and - on + multi-outlet devices - every outlet) - turning any one of them off + disables "switch" mode for the whole device. Only the RGB + color/brightness below is independently addressable per entity. """ if self._optimistic_is_on is not None: return self._optimistic_is_on @@ -127,14 +154,14 @@ def is_on(self) -> bool: def brightness(self) -> int: if self._optimistic_brightness is not None: return self._optimistic_brightness - b = self.led_config.get("colors", {}).get(self._switch_key, {}).get("on", {}).get("brightness", 100) + b = self.led_config.get("colors", {}).get(self._switch_key, {}).get(self._color_key, {}).get("brightness", 100) return round((b / 100) * 255) @property def rgb_color(self) -> tuple[int, int, int]: if self._optimistic_rgb is not None: return self._optimistic_rgb - rgb = self.led_config.get("colors", {}).get(self._switch_key, {}).get("on", {}).get("rgb", [100, 100, 100]) + rgb = self.led_config.get("colors", {}).get(self._switch_key, {}).get(self._color_key, {}).get("rgb", [100, 100, 100]) return (round((rgb[0]/100)*255), round((rgb[1]/100)*255), round((rgb[2]/100)*255)) @callback @@ -178,8 +205,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: "mode": "switch", "colors": { self._switch_key: { - "on": {"rgb": [r, g, b], "brightness": pct_b}, - "off": {"rgb": [r, g, b], "brightness": pct_b} + self._color_key: {"rgb": [r, g, b], "brightness": pct_b} } } } diff --git a/custom_components/shelly_plug_led/manifest.json b/custom_components/shelly_plug_led/manifest.json index 4172863..1c3f3c3 100644 --- a/custom_components/shelly_plug_led/manifest.json +++ b/custom_components/shelly_plug_led/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/radioactive-bbs/shelly_plug_led/issues", "requirements": [], - "version": "1.2.0" + "version": "1.3.0" } \ No newline at end of file From 3b84ebf980566242e73bedd0dc8ac286fb7e425b Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 23:10:19 +0200 Subject: [PATCH 4/8] Rename on-color entity to 'LED Ring On Color' for symmetry with off-color Was just 'LED Ring' before, which read oddly next to 'LED Ring Off Color'. Display name only - unique_id/entity_id unchanged, so this doesn't create new entities or orphan existing ones. --- README.md | 4 ++-- custom_components/shelly_plug_led/light.py | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f6b12bc..a4c0356 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@ This integration interacts with the LED configuration engine. It allows you to ## On-color vs. off-color -Each LED gets **two** light entities: e.g. `LED Ring` (the color shown while the relay is **on**) and `LED Ring Off Color` (shown while it's **off**). This mirrors the device's native "switch" LED mode, which already tracks that relay's own on/off state in firmware - so e.g. red-when-on / green-when-off needs no automation: set both colors once and the device handles the rest, even while Home Assistant is offline. Turning either entity off disables "switch" mode for the whole device (see below); turning it back on only ever rewrites its own color slot. +Each LED gets **two** light entities: `LED Ring On Color` (shown while the relay is **on**) and `LED Ring Off Color` (shown while it's **off**). This mirrors the device's native "switch" LED mode, which already tracks that relay's own on/off state in firmware - so e.g. red-when-on / green-when-off needs no automation: set both colors once and the device handles the rest, even while Home Assistant is offline. Turning either entity off disables "switch" mode for the whole device (see below); turning it back on only ever rewrites its own color slot. ## Multi-outlet devices (Shelly Power Strip) -On a Power Strip, one `LED Outlet N` / `LED Outlet N Off Color` pair is created per physical outlet found in the device's LED config (`switch:0`..`switch:3`). Note that on current Power Strip Gen4 firmware, the device actually reports only a single shared color slot (`switch:0`) for the whole strip rather than one per outlet - each outlet's physical LED still tracks *its own* relay state using that shared on/off color pair, so per-outlet red/green still works correctly, it's just configured once for the whole strip rather than per outlet. There's also a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by everything on the device - turning any one LED entity off switches LED mode off for the entire device. The "Reset LEDs to Default" button likewise resets the whole device, not a single outlet or color slot. +On a Power Strip, one `LED Outlet N On Color` / `LED Outlet N Off Color` pair is created per physical outlet found in the device's LED config (`switch:0`..`switch:3`). Note that on current Power Strip Gen4 firmware, the device actually reports only a single shared color slot (`switch:0`) for the whole strip rather than one per outlet - each outlet's physical LED still tracks *its own* relay state using that shared on/off color pair, so per-outlet red/green still works correctly, it's just configured once for the whole strip rather than per outlet. There's also a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by everything on the device - turning any one LED entity off switches LED mode off for the entire device. The "Reset LEDs to Default" button likewise resets the whole device, not a single outlet or color slot. --- diff --git a/custom_components/shelly_plug_led/light.py b/custom_components/shelly_plug_led/light.py index ec0e72f..9cab7ed 100644 --- a/custom_components/shelly_plug_led/light.py +++ b/custom_components/shelly_plug_led/light.py @@ -47,12 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e for switch_key in switch_keys: index = int(_SWITCH_KEY_RE.match(switch_key).group(1)) if multi: - base_name = f"LED Outlet {index + 1}" + ring_label = f"LED Outlet {index + 1}" base_suffix = f"led_{switch_key.replace(':', '_')}" else: - # Keep the original name/unique_id so existing single-outlet - # installs don't get a new entity_id after an update. - base_name = "LED Ring" + # Keep the original unique_id so existing single-outlet installs + # don't get a new entity_id after an update (display name is + # independent of unique_id, so renaming it below is safe). + ring_label = "LED Ring" base_suffix = "led_ring" entities.append( @@ -64,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e identifiers=entry.data.get("identifiers", []), switch_key=switch_key, color_key="on", - name=base_name, + name=f"{ring_label} On Color", unique_suffix=base_suffix, ) ) @@ -77,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e identifiers=entry.data.get("identifiers", []), switch_key=switch_key, color_key="off", - name=f"{base_name} Off Color", + name=f"{ring_label} Off Color", unique_suffix=f"{base_suffix}_off", ) ) @@ -107,7 +108,7 @@ def __init__( identifiers, switch_key: str = "switch:0", color_key: str = "on", - name: str = "LED Ring", + name: str = "LED Ring On Color", unique_suffix: str = "led_ring", ): super().__init__(coordinator) From 60767306bede001094f513b7cead7a99b5fcc076 Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 23:12:30 +0200 Subject: [PATCH 5/8] Make on/off color toggles fully independent, not mirrors of a shared mode Previously both entities' is_on read the same device-wide leds.mode, so they always showed the same state and turning either off ('mode: off') silently killed the other's color too - the toggle was effectively meaningless as a per-entity control. Now each entity's is_on is switch-mode-engaged AND its own slot's brightness > 0. turn_on engages switch mode and writes its own rgb/brightness (falling back to full brightness instead of a stale 0 when the slot was previously off). turn_off only dims its own slot to 0 brightness - it no longer touches leds.mode at all, so the sibling entity is completely unaffected. Updated README to describe the new independent on/off/off behavior. --- README.md | 6 ++- custom_components/shelly_plug_led/light.py | 42 ++++++++++++++----- .../shelly_plug_led/manifest.json | 2 +- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a4c0356..1cb5475 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,13 @@ This integration interacts with the LED configuration engine. It allows you to ## On-color vs. off-color -Each LED gets **two** light entities: `LED Ring On Color` (shown while the relay is **on**) and `LED Ring Off Color` (shown while it's **off**). This mirrors the device's native "switch" LED mode, which already tracks that relay's own on/off state in firmware - so e.g. red-when-on / green-when-off needs no automation: set both colors once and the device handles the rest, even while Home Assistant is offline. Turning either entity off disables "switch" mode for the whole device (see below); turning it back on only ever rewrites its own color slot. +Each LED gets **two** light entities: `LED Ring On Color` (shown while the relay is **on**) and `LED Ring Off Color` (shown while it's **off**). This mirrors the device's native "switch" LED mode, which already tracks that relay's own on/off state in firmware - so e.g. red-when-on / green-when-off needs no automation: set both colors once and the device handles the rest, even while Home Assistant is offline. + +The two entities are fully independent: turning one off just dims *its own* color slot to 0 brightness (so that state renders dark) without touching the other slot or the device's shared "switch" mode - turning one back on re-engages "switch" mode and restores only its own color. To go back to the device's factory power-tracking indicator (or make both states dark at once), use the "Reset LEDs to Default" button or turn off both entities. ## Multi-outlet devices (Shelly Power Strip) -On a Power Strip, one `LED Outlet N On Color` / `LED Outlet N Off Color` pair is created per physical outlet found in the device's LED config (`switch:0`..`switch:3`). Note that on current Power Strip Gen4 firmware, the device actually reports only a single shared color slot (`switch:0`) for the whole strip rather than one per outlet - each outlet's physical LED still tracks *its own* relay state using that shared on/off color pair, so per-outlet red/green still works correctly, it's just configured once for the whole strip rather than per outlet. There's also a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by everything on the device - turning any one LED entity off switches LED mode off for the entire device. The "Reset LEDs to Default" button likewise resets the whole device, not a single outlet or color slot. +On a Power Strip, one `LED Outlet N On Color` / `LED Outlet N Off Color` pair is created per physical outlet found in the device's LED config (`switch:0`..`switch:3`). Note that on current Power Strip Gen4 firmware, the device actually reports only a single shared color slot (`switch:0`) for the whole strip rather than one per outlet - each outlet's physical LED still tracks *its own* relay state using that shared on/off color pair, so per-outlet red/green still works correctly, it's just configured once for the whole strip rather than per outlet. There's also a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by everything on the device - the "Reset LEDs to Default" button resets the whole device's mode, not a single outlet or color slot. --- diff --git a/custom_components/shelly_plug_led/light.py b/custom_components/shelly_plug_led/light.py index 9cab7ed..436acc2 100644 --- a/custom_components/shelly_plug_led/light.py +++ b/custom_components/shelly_plug_led/light.py @@ -139,17 +139,21 @@ def led_config(self): @property def is_on(self) -> bool: - """Whether this LED's subsystem is in "switch" mode. - - Note: ``mode`` is a single firmware-wide setting shared by every - color-slot entity on the device (both on/off entities, and - on - multi-outlet devices - every outlet) - turning any one of them off - disables "switch" mode for the whole device. Only the RGB - color/brightness below is independently addressable per entity. + """Whether this specific color slot is active: switch-mode engaged AND its own brightness > 0. + + Each on/off color entity is independently switchable: turning one + off just dims its own slot to 0 brightness, leaving the sibling + entity (and the device's shared ``mode``) untouched. ``mode`` itself + is only ever engaged ("switch") by turning an entity *on* - never + forced to "off" by turning one off - so the two never fight over a + shared on/off state the way a single "whole subsystem" toggle would. """ if self._optimistic_is_on is not None: return self._optimistic_is_on - return self.led_config.get("mode") == "switch" + if self.led_config.get("mode") != "switch": + return False + b = self.led_config.get("colors", {}).get(self._switch_key, {}).get(self._color_key, {}).get("brightness", 0) + return b > 0 @property def brightness(self) -> int: @@ -188,7 +192,13 @@ async def _send_rpc(self, payload: dict): async def async_turn_on(self, **kwargs: Any) -> None: rgb = kwargs.get("rgb_color", self.rgb_color) - brightness = kwargs.get("brightness", self.brightness) + brightness = kwargs.get("brightness") + if brightness is None: + # Falling back to self.brightness would preserve 0 (this slot + # was just off), which is_on would immediately read back as + # "off" - default to full brightness instead, like a normal + # light turning on without an explicit level. + brightness = self.brightness or 255 self._optimistic_is_on = True self._optimistic_rgb = rgb @@ -215,8 +225,20 @@ async def async_turn_on(self, **kwargs: Any) -> None: await self._send_rpc(payload) async def async_turn_off(self, **kwargs: Any) -> None: + """Dim this color slot to 0 brightness - leaves ``mode`` and the sibling slot untouched.""" self._optimistic_is_on = False + self._optimistic_brightness = 0 self.async_write_ha_state() - payload = {"config": {"leds": {"mode": "off"}}} + payload = { + "config": { + "leds": { + "colors": { + self._switch_key: { + self._color_key: {"brightness": 0} + } + } + } + } + } await self._send_rpc(payload) diff --git a/custom_components/shelly_plug_led/manifest.json b/custom_components/shelly_plug_led/manifest.json index 1c3f3c3..0ec1082 100644 --- a/custom_components/shelly_plug_led/manifest.json +++ b/custom_components/shelly_plug_led/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/radioactive-bbs/shelly_plug_led/issues", "requirements": [], - "version": "1.3.0" + "version": "1.4.0" } \ No newline at end of file From f7a9ff91c9302f0712e7af73f9e7573e335af60b Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 23:26:38 +0200 Subject: [PATCH 6/8] Fix audit findings: device resolution, race condition, silent errors, header sanitization, credential redaction - config_flow.py: stop picking entry_devices[0] blindly. The official Shelly integration can register multiple devices per config entry on multi-outlet devices (a root device + one child per output); resolve the root device explicitly by identifier shape instead of list position, and fall back to sibling devices for a model string when the root device doesn't carry one itself. Confirmed live this session that list-position selection could land on a modelless device and behave inconsistently. - light.py: fix a real race between the on-color/off-color sibling entities that share one coordinator. A refresh triggered by one entity's write no longer clears the other entity's still-in-flight optimistic state - each entity now tracks its own pending-write count and only clears its optimistic overrides once its own writes have settled. - button.py: reset button no longer swallows every exception silently. Failures are now logged and raised as HomeAssistantError so a failed reset is visible in the UI instead of looking like it succeeded. - api.py: sanitize realm/nonce/opaque values taken from the device's WWW-Authenticate challenge before interpolating them into our own outgoing Digest Authorization header (defense-in-depth against header-value injection from a malicious/compromised device or LAN MITM). Also simplified get_config()'s component probing back to first-success-wins now that real-world testing confirmed a device only ever answers one of PLUGS_UI/POWERSTRIP_UI successfully - the 'probe all, keep the one with more outlets' comparison never had more than one candidate in practice. - diagnostics.py (new): redact the integration's own stored username/password copy from Home Assistant's diagnostics download, matching how the official Shelly integration already redacts its copy. Bump manifest version to 1.5.0. --- README.md | 112 +++++++++++++----- custom_components/shelly_plug_led/api.py | 51 ++++---- custom_components/shelly_plug_led/button.py | 21 +++- .../shelly_plug_led/config_flow.py | 33 ++++-- .../shelly_plug_led/diagnostics.py | 34 ++++++ custom_components/shelly_plug_led/light.py | 49 ++++++-- .../shelly_plug_led/manifest.json | 2 +- 7 files changed, 221 insertions(+), 81 deletions(-) create mode 100644 custom_components/shelly_plug_led/diagnostics.py diff --git a/README.md b/README.md index 1cb5475..43d11ec 100644 --- a/README.md +++ b/README.md @@ -2,64 +2,116 @@

GitHub repository + Latest release + MIT License

-> This is a fork of the original **[shelly_plug_led](https://github.com/ishiharas/shelly_plug_led)** by **[@ishiharas](https://github.com/ishiharas)** — all credit for the original design and implementation goes to them. This fork adds **Shelly Power Strip (Gen4)** support on top of it. +> This is a fork of the original **[shelly_plug_led](https://github.com/ishiharas/shelly_plug_led)** by **[@ishiharas](https://github.com/ishiharas)** — all credit for the original design and implementation goes to them. This fork adds **Shelly Power Strip (Gen4)** support, independent on/off color control, and a number of reliability fixes on top of it. -A custom Home Assistant integration that turns the built-in RGB LED(s) of your **Shelly Plug S (Gen2 / Gen3)** or **Shelly Power Strip (Gen4)** devices into independent, fully controllable smart light entities. - -This integration interacts with the LED configuration engine. It allows you to -- change colors -- apply dimming levels -- toggle the LED(s) on and off - -**without affecting the operational on/off power state of the actual smart plug/outlet relay(s)**. +A custom Home Assistant integration that turns the built-in RGB LED(s) of your **Shelly Plug S (Gen2 / Gen3)** or **Shelly Power Strip (Gen4)** devices into independent, fully controllable smart light entities — **without affecting the operational on/off power state of the actual smart plug/outlet relay(s)**.

- Alt text + Shelly Plug LED Ring banner

-## Prerequisites -1. You must have your Shelly plug(s) or power strip already configured and active in Home Assistant via the **official built-in Shelly integration**. -2. Your hardware must be a Generation 2/3 local RPC plug (such as the standard Shelly Plus Plug S or newer variants) or a Generation 4 Shelly Power Strip. +--- + +## Why this exists -## On-color vs. off-color +Shelly Gen2/Gen3/Gen4 devices already expose their status-LED configuration over their local RPC API, but the official Home Assistant Shelly integration doesn't surface it as controllable entities. This integration adds a thin layer on top of your **existing** official Shelly device: it reuses that device's connection details and credentials, and adds a handful of `light`/`button` entities attached to the same device card — no separate device, no separate credentials to manage. -Each LED gets **two** light entities: `LED Ring On Color` (shown while the relay is **on**) and `LED Ring Off Color` (shown while it's **off**). This mirrors the device's native "switch" LED mode, which already tracks that relay's own on/off state in firmware - so e.g. red-when-on / green-when-off needs no automation: set both colors once and the device handles the rest, even while Home Assistant is offline. +## Features -The two entities are fully independent: turning one off just dims *its own* color slot to 0 brightness (so that state renders dark) without touching the other slot or the device's shared "switch" mode - turning one back on re-engages "switch" mode and restores only its own color. To go back to the device's factory power-tracking indicator (or make both states dark at once), use the "Reset LEDs to Default" button or turn off both entities. +- **Color control** — pick any RGB color and brightness for the LED, independent of the relay's own on/off state. +- **Separate on-color / off-color** — the LED can show one color while the relay is on and a *different* color while it's off (e.g. red when on, green when off), configured once and then handled entirely by the device's own firmware — no Home Assistant automation needed, and it keeps working even while Home Assistant is offline or restarting. +- **Independent toggles** — the on-color and off-color entities switch on/off fully independently of each other; turning one off doesn't touch the other. +- **Reset to factory default** — a button to put the LED back into the device's original power-tracking indicator mode. +- **Multi-outlet aware** — automatically creates one on/off color pair per outlet on a Shelly Power Strip (see [caveat](#power-strip-caveat) below). +- **Auth-aware** — works whether or not the device has local RPC authentication enabled, and reuses the password already configured in the official Shelly integration (falling back to its own stored copy, with a reauth flow if that ever goes stale). -## Multi-outlet devices (Shelly Power Strip) +## Supported hardware -On a Power Strip, one `LED Outlet N On Color` / `LED Outlet N Off Color` pair is created per physical outlet found in the device's LED config (`switch:0`..`switch:3`). Note that on current Power Strip Gen4 firmware, the device actually reports only a single shared color slot (`switch:0`) for the whole strip rather than one per outlet - each outlet's physical LED still tracks *its own* relay state using that shared on/off color pair, so per-outlet red/green still works correctly, it's just configured once for the whole strip rather than per outlet. There's also a firmware limitation to be aware of: the LED **mode** (off / power-tracking / static-color) is a single setting shared by everything on the device - the "Reset LEDs to Default" button resets the whole device's mode, not a single outlet or color slot. +| Device family | Generation | Notes | +|---|---|---| +| Shelly Plug S | Gen2 / Gen3 | Local RPC (`PLUGS_UI` component) | +| Shelly Power Strip 4 | Gen4 | Local RPC (`POWERSTRIP_UI` component) — see the [Power Strip caveat](#power-strip-caveat) | + +Any device must already be set up and reachable through the **official built-in Shelly integration** (local RPC, not cloud-only/BLE-only setups) before it can be added here. --- ## Installation -### Method 1: Via HACS (Recommended) +### Method 1: Via HACS (recommended) 1. Open **HACS** in your Home Assistant sidebar. 2. Click the three dots `...` in the top-right corner and select **Custom repositories**. -3. Paste the URL of your GitHub repository into the *Repository* input. -4. Select **Integration** as the Category and click **Add**. -5. Find **Shelly Plug LED Ring** in the HACS interface and click **Download**. -6. **Restart Home Assistant Core** to load the custom workspace files. - -### Method 2: Manual Installation -1. Download the project repository source archive. -2. Extract the archive and copy the folder `config/custom_components/shelly_plug_led` directly into your Home Assistant runtime directory. +3. Add `https://github.com/radioactive-bbs/shelly_plug_led` as the repository URL, with category **Integration**. +4. Find **Shelly Plug LED Ring** in the HACS interface and click **Download**. +5. **Restart Home Assistant Core** to load it. + +### Method 2: Manual installation +1. Download the [latest release](https://github.com/radioactive-bbs/shelly_plug_led/releases) source archive (or clone the repo). +2. Copy the `custom_components/shelly_plug_led` folder into your Home Assistant `config/custom_components/` directory. 3. **Restart Home Assistant Core**. --- ## Configuration -1. In Home Assistant, navigate to **Settings > Devices & Services**. -2. Click the **Add Integration** button in the bottom right corner. +1. Make sure the target plug/power strip is already set up via the official **Shelly** integration (Settings → Devices & Services). +2. In Home Assistant, go to **Settings → Devices & Services → Add Integration**. 3. Search for **Shelly Plug LED Ring** and select it. +4. Pick the device from the dropdown (only devices recognized as a supported plug or power strip are listed) and confirm. +5. Repeat per device — each Shelly plug/power strip needs its own config entry. + +If the device requires authentication, credentials are pulled automatically from the official Shelly integration's entry for that device. If that ever fails (e.g. the official entry was removed or the password changed there without a corresponding update here), a **reauthentication** prompt will appear under **Settings → Devices & Services**. + +--- + +## Entities created + +For a **single-outlet** device (Shelly Plug S): + +| Entity | Domain | What it controls | +|---|---|---| +| `LED Ring On Color` | `light` | Color/brightness shown while the relay is **on** | +| `LED Ring Off Color` | `light` | Color/brightness shown while the relay is **off** | +| `Reset LEDs to Default` | `button` | Puts the LED back into the device's factory power-tracking mode | + +For a **Power Strip**, the same pair is created per outlet found in the device's LED configuration (currently `LED Outlet 1 On/Off Color` for the single shared slot the Gen4 firmware exposes — see below). + +### On-color vs. off-color + +Turning the **on-color** entity on/off engages/disengages the device's native "switch" LED mode and sets the color shown while the relay is on. Turning the **off-color** entity on/off does the same for the color shown while the relay is off. The two are fully independent: + +- Turning an entity **on** engages switch mode (if not already) and writes only *that* entity's own color slot. +- Turning an entity **off** just dims *that* slot's brightness to 0 — it does **not** touch switch mode or the sibling entity, so the other color keeps working normally. +- To go back to the device's original factory indicator (power-draw color), or to make the LED dark for both states at once, use the **Reset LEDs to Default** button, or turn both entities off. + +Because the color mapping is written straight to the device's own `leds` config, red-on/green-off (or any other combination) needs **no HA automation at all** — set both colors once and the firmware handles switching between them on every relay toggle, including while Home Assistant itself is offline. + +### Power Strip caveat + +On a Shelly Power Strip Gen4 (firmware 2.0.0, confirmed by testing against real hardware), the device only exposes **one shared** on/off color slot for the whole strip, not one per physical outlet, even though it has 4 individually switchable relays. In practice this doesn't stop per-outlet red/on-green/off from working: **each outlet's own LED independently tracks its own relay's state** using that one shared color pair, confirmed by testing — so setting on=red / off=green once still makes outlet 1's LED show red exactly when outlet 1 is on, independent of the other 3 outlets. Only the *color choice itself* isn't independently configurable per outlet on current firmware — if Shelly ships a firmware update that exposes `switch:1`..`switch:3` color slots individually, this integration will automatically create one on/off pair per outlet without any changes needed (it already discovers outlets dynamically from the device's own config). + +The LED **mode** (off / power-tracking / switch) is also a single firmware-wide setting — the **Reset LEDs to Default** button resets it for the whole device, not per outlet or per color slot. + +--- + +## Troubleshooting + +- **"No compatible Shelly plugs or power strips found"** — the device must already exist as a config entry under the official **Shelly** integration (not just discovered), and its model needs to be recognized as a supported plug or power strip. Devices already configured in this integration are hidden from the list. +- **Re-authentication requested** — the stored password no longer works (e.g. it was changed on the device or in the official Shelly integration's config). Follow the reauth prompt under **Settings → Devices & Services**, or fix it in the official Shelly integration entry so it can be picked up automatically on the next restart/reload. +- **Colors look "washed out" or wrong** — the device's own RGB range is 0–100 (percent) internally; this integration converts to/from Home Assistant's 0–255 range, so values are rounded and won't always be pixel/byte-exact round-trips. +- **Reset button did nothing** — as of this integration's error-surfacing update, a failed reset now raises a visible error in Home Assistant instead of failing silently; check **Settings → System → Logs** for the underlying reason (commonly a stale/incorrect password). +- **Downloading diagnostics** — this integration ships a diagnostics provider (Settings → Devices & Services → the integration → Download diagnostics) that redacts the stored username/password automatically before sharing. --- ## Credits -Originally created by **[@ishiharas](https://github.com/ishiharas)** — see the upstream project at [ishiharas/shelly_plug_led](https://github.com/ishiharas/shelly_plug_led). This fork ([@radioactive-bbs](https://github.com/radioactive-bbs)) builds on that work to add Shelly Power Strip (Gen4) multi-outlet support. +Originally created by **[@ishiharas](https://github.com/ishiharas)** — see the upstream project at [ishiharas/shelly_plug_led](https://github.com/ishiharas/shelly_plug_led). This fork ([@radioactive-bbs](https://github.com/radioactive-bbs)) builds on that work to add Shelly Power Strip (Gen4) support, independent on/off color entities, and several reliability/security hardening fixes. See [Releases](https://github.com/radioactive-bbs/shelly_plug_led/releases) for the full change history. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/custom_components/shelly_plug_led/api.py b/custom_components/shelly_plug_led/api.py index de8c011..50a9465 100644 --- a/custom_components/shelly_plug_led/api.py +++ b/custom_components/shelly_plug_led/api.py @@ -42,6 +42,20 @@ def _sha256(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() +def _sanitize_challenge_value(value: str) -> str: + """Strip characters that could break out of a quoted Digest header value. + + ``realm``/``nonce``/``opaque`` come verbatim from the device's + WWW-Authenticate response and are interpolated into our own outgoing + Authorization header as quoted strings - an embedded ``"`` (or a stray + CR/LF) would corrupt that header's structure. The only party able to + supply a malicious challenge here already has full visibility of this + same connection (the device itself, or a LAN MITM), so this is + defense-in-depth rather than a fix for a reachable exploit. + """ + return value.replace('"', "").replace("\r", "").replace("\n", "") + + def _parse_challenge(header: str) -> dict[str, str]: """Parse a ``WWW-Authenticate: Digest ...`` header into a dict of params.""" header = header.strip() @@ -97,9 +111,9 @@ def set_credentials(self, username: str | None, password: str | None) -> None: def _build_digest_header(self, www_auth: str, method: str, uri: str) -> str: params = _parse_challenge(www_auth) - realm = params.get("realm", "") - nonce = params.get("nonce", "") - qop = params.get("qop", "auth") + realm = _sanitize_challenge_value(params.get("realm", "")) + nonce = _sanitize_challenge_value(params.get("nonce", "")) + qop = _sanitize_challenge_value(params.get("qop", "auth")) cnonce = secrets.token_hex(8) nc = "00000001" @@ -119,7 +133,7 @@ def _build_digest_header(self, www_auth: str, method: str, uri: str) -> str: f'cnonce="{cnonce}"', ] if "opaque" in params: - parts.append(f'opaque="{params["opaque"]}"') + parts.append(f'opaque="{_sanitize_challenge_value(params["opaque"])}"') return "Digest " + ", ".join(parts) async def _handle(self, res: aiohttp.ClientResponse) -> dict: @@ -159,27 +173,18 @@ async def call(self, method: str, params: dict | None = None) -> dict: ) return await self._handle(res) - @staticmethod - def _switch_count(result: dict) -> int: - """Count switch:N keys in a GetConfig result's leds.colors.""" - colors = result.get("leds", {}).get("colors", {}) - return sum(1 for key in colors if key.startswith("switch:")) - async def get_config(self) -> dict: """Fetch the LED config, auto-detecting which RPC component the device exposes. - Some devices (e.g. Power Strip Gen4) answer *both* PLUGS_UI and - POWERSTRIP_UI - PLUGS_UI apparently as a single-outlet compatibility - shim that only reports switch:0. Stopping at the first successful - component would silently lock onto that shim on a multi-outlet - device, so every component is probed and the one whose config - reports the most outlets (switch:N keys) is kept. The winner is - cached, so this only costs the extra round-trip once. + Verified against real hardware: a device answers exactly one of + LED_UI_COMPONENTS (PLUGS_UI on a Plug S, POWERSTRIP_UI on a Power + Strip Gen4) and 404s outright on the other, so first-success-wins is + sufficient - no need to probe every component and compare results. + The winner is cached, so later calls (and set_config) skip probing. """ if self._ui_component: return await self.call(f"{self._ui_component}.GetConfig") - candidates: list[tuple[str, dict]] = [] last_err: Exception | None = None for component in LED_UI_COMPONENTS: try: @@ -189,14 +194,10 @@ async def get_config(self) -> dict: except Exception as err: # noqa: BLE001 - probing; any failure means "try next" last_err = err continue - candidates.append((component, result)) - - if not candidates: - raise last_err or RuntimeError("Device exposes no supported LED UI component") + self._ui_component = component + return result - component, result = max(candidates, key=lambda item: self._switch_count(item[1])) - self._ui_component = component - return result + raise last_err or RuntimeError("Device exposes no supported LED UI component") async def set_config(self, config: dict) -> dict: if not self._ui_component: diff --git a/custom_components/shelly_plug_led/button.py b/custom_components/shelly_plug_led/button.py index 07b2ae9..1a8a2e1 100644 --- a/custom_components/shelly_plug_led/button.py +++ b/custom_components/shelly_plug_led/button.py @@ -1,12 +1,17 @@ +import logging + from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import CoordinatorEntity from .api import ShellyAuthError +_LOGGER = logging.getLogger(__name__) + DOMAIN = "shelly_plug_led" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: @@ -49,12 +54,18 @@ def device_info(self): return None async def async_press(self) -> None: - """Handle the button press to revert the LED ring mode back to power tracking.""" + """Handle the button press to revert the LED mode back to power tracking.""" try: await self._client.set_config({"leds": {"mode": "power"}}) - except ShellyAuthError: - pass # Coordinator's next poll will surface the reauth flow. - except Exception: - pass + except ShellyAuthError as err: + # Let the coordinator surface the reauth flow, but still fail the + # press visibly rather than silently pretending it worked. + await self.coordinator.async_request_refresh() + raise HomeAssistantError( + f"Shelly device at {self._host} rejected the request - re-authentication needed" + ) from err + except Exception as err: + _LOGGER.error("Error resetting Shelly LED at %s: %s", self._host, err) + raise HomeAssistantError(f"Failed to reset Shelly LED at {self._host}: {err}") from err await self.coordinator.async_request_refresh() diff --git a/custom_components/shelly_plug_led/config_flow.py b/custom_components/shelly_plug_led/config_flow.py index 736408c..165e0bc 100644 --- a/custom_components/shelly_plug_led/config_flow.py +++ b/custom_components/shelly_plug_led/config_flow.py @@ -4,7 +4,7 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .api import ShellyAuthError, ShellyRpcClient, get_shelly_credentials +from .api import SHELLY_DOMAIN, ShellyAuthError, ShellyRpcClient, get_shelly_credentials DOMAIN = "shelly_plug_led" @@ -28,7 +28,7 @@ async def async_step_user(self, user_input=None): } # Pull all active configuration entries matching the official Shelly domain - shelly_entries = self.hass.config_entries.async_entries("shelly") + shelly_entries = self.hass.config_entries.async_entries(SHELLY_DOMAIN) devices = {} for entry in shelly_entries: @@ -39,13 +39,32 @@ async def async_step_user(self, user_input=None): entry_devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) if not entry_devices: continue - - device = entry_devices[0] - + + # The official Shelly integration can register more than one + # device per config entry: on newer multi-outlet devices (e.g. + # Power Strip Gen4) it splits into one physical "root" device + # (bare "shelly:" identifier, carries the shared LED + # config) plus one child device per output ("shelly:-switch:N", + # carries that output's own model/energy sensors). Picking + # entry_devices[0] blindly is not safe - registry order isn't + # guaranteed, and it can land on a child or a root device with + # no model string. Prefer the root device explicitly; fall back + # to whatever's there for older/simpler single-device setups. + root_devices = [ + d for d in entry_devices + if not any( + len(ident) == 2 and ident[0] == SHELLY_DOMAIN and "-switch:" in ident[1] + for ident in d.identifiers + ) + ] + device = root_devices[0] if root_devices else entry_devices[0] + # Filter: only show devices whose LED subsystem we know how to # drive - single-outlet plugs (PLUGS_UI) and multi-outlet power - # strips (POWERSTRIP_UI, e.g. "Shelly Power Strip 4 Gen4"). - model = device.model or "" + # strips (POWERSTRIP_UI, e.g. "Shelly Power Strip 4 Gen4"). The + # root device may not carry a model string itself (see above) - + # fall back to checking its sibling devices for one. + model = device.model or next((d.model for d in entry_devices if d.model), "") or "" model_lower = model.lower() if not any(keyword in model_lower for keyword in ("plug", "power strip", "powerstrip")): continue diff --git a/custom_components/shelly_plug_led/diagnostics.py b/custom_components/shelly_plug_led/diagnostics.py new file mode 100644 index 0000000..6fd8a3b --- /dev/null +++ b/custom_components/shelly_plug_led/diagnostics.py @@ -0,0 +1,34 @@ +"""Diagnostics support for Shelly Plug LED Ring. + +This integration stores its own copy of the Shelly device's username/ +password (see api.get_shelly_credentials / __init__._resolve_credentials) +as a resilience fallback for when the official ``shelly`` config entry is +unavailable. That's a second at-rest copy of the credential alongside the +official integration's own - this file makes sure Home Assistant's +"Download diagnostics" feature redacts it here too, the same way it +already redacts the official integration's copy. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +DOMAIN = "shelly_plug_led" +TO_REDACT = {"password", "username"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry, with credentials redacted.""" + data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {}) + coordinator = data.get("coordinator") + + return { + "entry_data": async_redact_data(dict(entry.data), TO_REDACT), + "led_config": coordinator.data if coordinator else None, + } diff --git a/custom_components/shelly_plug_led/light.py b/custom_components/shelly_plug_led/light.py index 436acc2..24aebc7 100644 --- a/custom_components/shelly_plug_led/light.py +++ b/custom_components/shelly_plug_led/light.py @@ -124,6 +124,7 @@ def __init__( self._optimistic_is_on = None self._optimistic_brightness = None self._optimistic_rgb = None + self._pending_writes = 0 @property def device_info(self): @@ -171,24 +172,46 @@ def rgb_color(self) -> tuple[int, int, int]: @callback def _handle_coordinator_update(self) -> None: - """Clear optimistic state overrides once the coordinator refreshes successfully.""" - self._optimistic_is_on = None - self._optimistic_brightness = None - self._optimistic_rgb = None + """Clear optimistic state overrides once this entity's own writes have all settled. + + The on-color and off-color entities for one LED share a single + coordinator, so a refresh triggered by the *sibling* entity's write + also fires here. Only clear our optimistic values when we have no + write of our own still in flight (``_pending_writes == 0``) - + otherwise a sibling's refresh landing mid-write would wipe our + just-set optimistic state and flicker the UI back to the stale + pre-write value until our own write's refresh arrives. + """ + if self._pending_writes == 0: + self._optimistic_is_on = None + self._optimistic_brightness = None + self._optimistic_rgb = None super()._handle_coordinator_update() async def _send_rpc(self, payload: dict): + self._pending_writes += 1 try: - await self._client.set_config(payload["config"]) - except ShellyAuthError: - # Let the coordinator surface the reauth flow on its next poll. - await self.coordinator.async_request_refresh() - return - except Exception as err: - _LOGGER.error("Error communicating with Shelly LED Ring at %s: %s", self._host, err) + try: + await self._client.set_config(payload["config"]) + except ShellyAuthError: + # Let the coordinator surface the reauth flow on its next poll. + await self.coordinator.async_request_refresh() + return + except Exception as err: + _LOGGER.error("Error communicating with Shelly LED Ring at %s: %s", self._host, err) - await asyncio.sleep(1.5) - await self.coordinator.async_request_refresh() + await asyncio.sleep(1.5) + await self.coordinator.async_request_refresh() + finally: + self._pending_writes -= 1 + if self._pending_writes == 0: + # Our last in-flight write has settled - clear optimistic + # overrides now rather than waiting for the next unrelated + # coordinator refresh to happen to come along. + self._optimistic_is_on = None + self._optimistic_brightness = None + self._optimistic_rgb = None + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: rgb = kwargs.get("rgb_color", self.rgb_color) diff --git a/custom_components/shelly_plug_led/manifest.json b/custom_components/shelly_plug_led/manifest.json index 0ec1082..eb62227 100644 --- a/custom_components/shelly_plug_led/manifest.json +++ b/custom_components/shelly_plug_led/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/radioactive-bbs/shelly_plug_led/issues", "requirements": [], - "version": "1.4.0" + "version": "1.5.0" } \ No newline at end of file From 01ef3314ac6c27f728e6ed4a90b450071f439054 Mon Sep 17 00:00:00 2001 From: radioactive-bbs Date: Wed, 5 Aug 2026 23:35:03 +0200 Subject: [PATCH 7/8] README: replace static banner with real dashboard/entity screenshots Swaps the old custom_components/shelly_plug_led/brand/banner.png for two real screenshots showing the On Color / Off Color toggles on the device's control card, and the Off Color picker set to green. --- README.md | 4 +++- screenshots/controls.png | Bin 0 -> 12182 bytes screenshots/off-color-picker.png | Bin 0 -> 28127 bytes 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 screenshots/controls.png create mode 100644 screenshots/off-color-picker.png diff --git a/README.md b/README.md index 43d11ec..180f5f8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ A custom Home Assistant integration that turns the built-in RGB LED(s) of your **Shelly Plug S (Gen2 / Gen3)** or **Shelly Power Strip (Gen4)** devices into independent, fully controllable smart light entities — **without affecting the operational on/off power state of the actual smart plug/outlet relay(s)**.

- Shelly Plug LED Ring banner + LED Ring On Color and Off Color toggles on the device's control card +    + Color picker for LED Ring Off Color, set to green

--- diff --git a/screenshots/controls.png b/screenshots/controls.png new file mode 100644 index 0000000000000000000000000000000000000000..00f3f36a5a931d7ee3085f02c5ce034c43cffda5 GIT binary patch literal 12182 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 Juji&8{}=9Du6zIh literal 0 HcmV?d00001 diff --git a/screenshots/off-color-picker.png b/screenshots/off-color-picker.png new file mode 100644 index 0000000000000000000000000000000000000000..7fc0dc64c6517916300197c64caa7873985363c3 GIT binary patch literal 28127 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 Date: Wed, 5 Aug 2026 23:40:21 +0200 Subject: [PATCH 8/8] Point README/manifest at upstream instead of the fork, for the upstream PR Only self-referential branding changes on top of main - all functional changes (Power Strip support, on/off color entities, audit fixes, diagnostics, screenshots) are carried over as-is. --- README.md | 19 ++++++++----------- .../shelly_plug_led/manifest.json | 6 +++--- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 180f5f8..850d049 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ # Shelly Plug LED Ring Integration for Home Assistant

- GitHub repository - Latest release - MIT License + GitHub repository + Latest release + MIT License

-> This is a fork of the original **[shelly_plug_led](https://github.com/ishiharas/shelly_plug_led)** by **[@ishiharas](https://github.com/ishiharas)** — all credit for the original design and implementation goes to them. This fork adds **Shelly Power Strip (Gen4)** support, independent on/off color control, and a number of reliability fixes on top of it. - A custom Home Assistant integration that turns the built-in RGB LED(s) of your **Shelly Plug S (Gen2 / Gen3)** or **Shelly Power Strip (Gen4)** devices into independent, fully controllable smart light entities — **without affecting the operational on/off power state of the actual smart plug/outlet relay(s)**.

@@ -46,13 +44,12 @@ Any device must already be set up and reachable through the **official built-in ### Method 1: Via HACS (recommended) 1. Open **HACS** in your Home Assistant sidebar. -2. Click the three dots `...` in the top-right corner and select **Custom repositories**. -3. Add `https://github.com/radioactive-bbs/shelly_plug_led` as the repository URL, with category **Integration**. -4. Find **Shelly Plug LED Ring** in the HACS interface and click **Download**. -5. **Restart Home Assistant Core** to load it. +2. Search for **Shelly Plug LED Ring** in the HACS interface and click **Download**. + - If it doesn't show up (e.g. on an older HACS version), add it manually first: three dots `...` → **Custom repositories** → `https://github.com/ishiharas/shelly_plug_led`, category **Integration**. +3. **Restart Home Assistant Core** to load it. ### Method 2: Manual installation -1. Download the [latest release](https://github.com/radioactive-bbs/shelly_plug_led/releases) source archive (or clone the repo). +1. Download the [latest release](https://github.com/ishiharas/shelly_plug_led/releases) source archive (or clone the repo). 2. Copy the `custom_components/shelly_plug_led` folder into your Home Assistant `config/custom_components/` directory. 3. **Restart Home Assistant Core**. @@ -112,7 +109,7 @@ The LED **mode** (off / power-tracking / switch) is also a single firmware-wide ## Credits -Originally created by **[@ishiharas](https://github.com/ishiharas)** — see the upstream project at [ishiharas/shelly_plug_led](https://github.com/ishiharas/shelly_plug_led). This fork ([@radioactive-bbs](https://github.com/radioactive-bbs)) builds on that work to add Shelly Power Strip (Gen4) support, independent on/off color entities, and several reliability/security hardening fixes. See [Releases](https://github.com/radioactive-bbs/shelly_plug_led/releases) for the full change history. +Created by **[@ishiharas](https://github.com/ishiharas)**. Shelly Power Strip (Gen4) support, independent on/off color entities, and several reliability/security hardening fixes contributed by **[@radioactive-bbs](https://github.com/radioactive-bbs)**. See [Releases](https://github.com/ishiharas/shelly_plug_led/releases) for the full change history. ## License diff --git a/custom_components/shelly_plug_led/manifest.json b/custom_components/shelly_plug_led/manifest.json index eb62227..4c41b7a 100644 --- a/custom_components/shelly_plug_led/manifest.json +++ b/custom_components/shelly_plug_led/manifest.json @@ -1,11 +1,11 @@ { "domain": "shelly_plug_led", "name": "Shelly Plug LED Ring", - "codeowners": ["@radioactive-bbs", "@ishiharas"], + "codeowners": ["@ishiharas", "@radioactive-bbs"], "config_flow": true, - "documentation": "https://github.com/radioactive-bbs/shelly_plug_led", + "documentation": "https://github.com/ishiharas/shelly_plug_led", "iot_class": "local_polling", - "issue_tracker": "https://github.com/radioactive-bbs/shelly_plug_led/issues", + "issue_tracker": "https://github.com/ishiharas/shelly_plug_led/issues", "requirements": [], "version": "1.5.0" } \ No newline at end of file