Skip to content
Open
119 changes: 96 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,116 @@
# 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.

This integration interacts with the LED configuration engine. It allows you to
- change colors
- apply dimming levels
- toggle the ring on and off
<p align="right">
<a href="https://github.com/ishiharas/shelly_plug_led"><img src="https://img.shields.io/badge/GitHub-ishiharas%2Fshelly__plug__led-181717?logo=github&logoColor=white" alt="GitHub repository"></a>
<a href="https://github.com/ishiharas/shelly_plug_led/releases"><img src="https://img.shields.io/github/v/release/ishiharas/shelly_plug_led?label=release" alt="Latest release"></a>
<a href="https://github.com/ishiharas/shelly_plug_led/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
</p>

**without affecting the operational on/off power state of the actual smart plug relay**.
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)**.

<p align="center">
<img src="custom_components/shelly_plug_led/brand/banner.png" alt="Alt text" width="468">
<img src="screenshots/controls.png" alt="LED Ring On Color and Off Color toggles on the device's control card" width="380">
&nbsp;&nbsp;
<img src="screenshots/off-color-picker.png" alt="Color picker for LED Ring Off Color, set to green" width="260">
</p>

## 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).
---

## Why this exists

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.

## Features

- **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).

## Supported hardware

| 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.
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/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**.

---

## 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

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

MIT — see [LICENSE](LICENSE).
59 changes: 53 additions & 6 deletions custom_components/shelly_plug_led/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand All @@ -35,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()
Expand Down Expand Up @@ -81,6 +102,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."""
Expand All @@ -89,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"

Expand All @@ -111,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:
Expand Down Expand Up @@ -152,10 +174,35 @@ 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.

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

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):
Expand Down
29 changes: 22 additions & 7 deletions custom_components/shelly_plug_led/button.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -23,11 +28,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):
Expand All @@ -45,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()
Loading