Skip to content

Commit 1d2f519

Browse files
committed
feat: dynamic device discovery and add D36HD dimmer support
Add D36HD model (Decora Smart Wi-Fi 2 / Matter 600W Dimmer) to the supported lights list so newer dimmers like the office main light are exposed. Each platform now listens to coordinator updates and creates entities for newly-discovered devices instead of only enumerating once at setup. The WebSocket now subscribes to new device IDs as they appear, so real-time updates work for devices added after HA started. Bump version to 1.1.4.
1 parent 8994b24 commit 1d2f519

8 files changed

Lines changed: 137 additions & 57 deletions

File tree

custom_components/leviton_smart/__init__.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from typing import Dict, Any, List
1616

1717
from homeassistant.config_entries import ConfigEntry
18-
from homeassistant.core import HomeAssistant
18+
from homeassistant.core import HomeAssistant, callback
1919
from homeassistant.helpers import device_registry as dr
2020
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
2121
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -89,6 +89,9 @@ async def async_update_data():
8989
# Initial fetch
9090
await coordinator.async_config_entry_first_refresh()
9191

92+
# Track which device IDs the WebSocket has subscribed to.
93+
known_ws_ids: set[str] = set()
94+
9295
# Callback for WebSocket updates
9396
def on_update(data: Dict[str, Any]) -> None:
9497
"""
@@ -112,9 +115,23 @@ def on_update(data: Dict[str, Any]) -> None:
112115
# Initialize and start WebSocket
113116
ws = LevitonWebSocket(session, login_response, on_update)
114117
_LOGGER.info("Starting WebSocket connection...")
115-
118+
116119
# device_ids are keys in coordinator.data
117-
ws.start(list(coordinator.data.keys()))
120+
initial_ids = [str(k) for k in coordinator.data.keys()]
121+
known_ws_ids.update(initial_ids)
122+
ws.start(initial_ids)
123+
124+
# Subscribe to any new devices that appear on subsequent coordinator refreshes.
125+
@callback
126+
def _subscribe_new_devices() -> None:
127+
new_ids = [str(d) for d in coordinator.data.keys() if str(d) not in known_ws_ids]
128+
for device_id in new_ids:
129+
known_ws_ids.add(device_id)
130+
hass.async_create_task(ws.add_device(device_id))
131+
if new_ids:
132+
_LOGGER.info("Discovered %d new Leviton device(s): %s", len(new_ids), new_ids)
133+
134+
entry.async_on_unload(coordinator.async_add_listener(_subscribe_new_devices))
118135

119136
# Store everything in hass.data for platforms to access
120137
hass.data[DOMAIN][entry.entry_id] = {

custom_components/leviton_smart/binary_sensor.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
BinarySensorDeviceClass,
1414
)
1515
from homeassistant.config_entries import ConfigEntry
16-
from homeassistant.core import HomeAssistant
16+
from homeassistant.core import HomeAssistant, callback
1717
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1818

1919
from .const import DOMAIN, MODELS_MOTION_SENSOR
@@ -31,15 +31,26 @@ async def async_setup_entry(
3131
client = hass.data[DOMAIN][config_entry.entry_id]["client"]
3232
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
3333

34-
entities = []
34+
known_ids: set[str] = set()
3535

36-
for device_id, device_data in coordinator.data.items():
37-
model = device_data.get("model", "")
38-
# Create motion sensor entities for motion-capable models
39-
if model in MODELS_MOTION_SENSOR:
40-
entities.append(LevitonMotionSensor(client, coordinator, device_id, config_entry.entry_id))
36+
@callback
37+
def _add_new_entities() -> None:
38+
new_entities = []
39+
for device_id, device_data in coordinator.data.items():
40+
device_id = str(device_id)
41+
if device_id in known_ids:
42+
continue
43+
model = device_data.get("model", "")
44+
if model in MODELS_MOTION_SENSOR:
45+
known_ids.add(device_id)
46+
new_entities.append(
47+
LevitonMotionSensor(client, coordinator, device_id, config_entry.entry_id)
48+
)
49+
if new_entities:
50+
async_add_entities(new_entities)
4151

42-
async_add_entities(entities)
52+
_add_new_entities()
53+
config_entry.async_on_unload(coordinator.async_add_listener(_add_new_entities))
4354

4455

4556
class LevitonMotionSensor(LevitonEntity, BinarySensorEntity):

custom_components/leviton_smart/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"D26HD", # 600W Dimmer
3030
"D2ELV", # ELV Dimmer
3131
"D2MSD", # Motion Sensor Dimmer
32+
"D36HD", # Decora Smart Wi-Fi 2 / Matter 600W Dimmer
3233
"DW1KD", # 1000W Dimmer
3334
"DW3HL", # 300W Dimmer
3435
"DW6HD", # 600W Dimmer

custom_components/leviton_smart/fan.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from homeassistant.components.fan import FanEntity, FanEntityFeature
1414
from homeassistant.config_entries import ConfigEntry
15-
from homeassistant.core import HomeAssistant
15+
from homeassistant.core import HomeAssistant, callback
1616
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1717
from homeassistant.util.percentage import (
1818
ordered_list_item_to_percentage,
@@ -42,14 +42,26 @@ async def async_setup_entry(
4242
client = hass.data[DOMAIN][config_entry.entry_id]["client"]
4343
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
4444

45-
entities = []
46-
47-
for device_id, device_data in coordinator.data.items():
48-
model = device_data.get("model", "")
49-
if model in MODELS_FAN:
50-
entities.append(LevitonFan(client, coordinator, device_id, config_entry.entry_id))
51-
52-
async_add_entities(entities)
45+
known_ids: set[str] = set()
46+
47+
@callback
48+
def _add_new_entities() -> None:
49+
new_entities = []
50+
for device_id, device_data in coordinator.data.items():
51+
device_id = str(device_id)
52+
if device_id in known_ids:
53+
continue
54+
model = device_data.get("model", "")
55+
if model in MODELS_FAN:
56+
known_ids.add(device_id)
57+
new_entities.append(
58+
LevitonFan(client, coordinator, device_id, config_entry.entry_id)
59+
)
60+
if new_entities:
61+
async_add_entities(new_entities)
62+
63+
_add_new_entities()
64+
config_entry.async_on_unload(coordinator.async_add_listener(_add_new_entities))
5365

5466

5567
class LevitonFan(LevitonEntity, FanEntity):

custom_components/leviton_smart/leviton_api/websocket.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -190,25 +190,42 @@ async def _subscribe_all(self) -> None:
190190
return
191191

192192
for device_id in self._device_ids:
193-
# modelId must be an integer in the subscription
194-
try:
195-
model_id = int(device_id)
196-
except (ValueError, TypeError):
197-
_LOGGER.warning("Invalid device ID format: %s", device_id)
198-
continue
199-
200-
payload = {
201-
"type": "subscribe",
202-
"subscription": {
203-
"modelName": "IotSwitch",
204-
"modelId": model_id,
205-
}
206-
}
207-
_LOGGER.debug("Subscribing to device %d", model_id)
208-
await self._ws.send_json(payload)
193+
await self._send_subscribe(device_id)
209194

210195
_LOGGER.info("Subscribed to %d devices.", len(self._device_ids))
211196

197+
async def _send_subscribe(self, device_id: str) -> None:
198+
"""Send a single subscribe payload for a device."""
199+
if not self._ws:
200+
return
201+
try:
202+
model_id = int(device_id)
203+
except (ValueError, TypeError):
204+
_LOGGER.warning("Invalid device ID format: %s", device_id)
205+
return
206+
207+
payload = {
208+
"type": "subscribe",
209+
"subscription": {
210+
"modelName": "IotSwitch",
211+
"modelId": model_id,
212+
}
213+
}
214+
_LOGGER.debug("Subscribing to device %d", model_id)
215+
await self._ws.send_json(payload)
216+
217+
async def add_device(self, device_id: str) -> None:
218+
"""
219+
Track a newly-discovered device and subscribe to it if connected.
220+
Safe to call repeatedly; duplicates are ignored.
221+
"""
222+
device_id = str(device_id)
223+
if device_id in self._device_ids:
224+
return
225+
self._device_ids.append(device_id)
226+
if self._ws is not None and not self._ws.closed:
227+
await self._send_subscribe(device_id)
228+
212229
def _process_notification(self, data: Dict[str, Any]) -> None:
213230
"""
214231
Extract meaningful data from a notification and trigger the update callback.

custom_components/leviton_smart/light.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
ATTR_BRIGHTNESS,
1919
)
2020
from homeassistant.config_entries import ConfigEntry
21-
from homeassistant.core import HomeAssistant
21+
from homeassistant.core import HomeAssistant, callback
2222
from homeassistant.helpers.entity_platform import AddEntitiesCallback
2323

2424
from .const import DOMAIN, MODELS_LIGHT, MODELS_FAN
@@ -36,15 +36,26 @@ async def async_setup_entry(
3636
client = hass.data[DOMAIN][config_entry.entry_id]["client"]
3737
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
3838

39-
entities = []
40-
41-
for device_id, device_data in coordinator.data.items():
42-
model = device_data.get("model", "")
43-
# Only create light entities for dimmer models (not fans, not switches)
44-
if model in MODELS_LIGHT and model not in MODELS_FAN:
45-
entities.append(LevitonDimmer(client, coordinator, device_id, config_entry.entry_id))
46-
47-
async_add_entities(entities)
39+
known_ids: set[str] = set()
40+
41+
@callback
42+
def _add_new_entities() -> None:
43+
new_entities = []
44+
for device_id, device_data in coordinator.data.items():
45+
device_id = str(device_id)
46+
if device_id in known_ids:
47+
continue
48+
model = device_data.get("model", "")
49+
if model in MODELS_LIGHT and model not in MODELS_FAN:
50+
known_ids.add(device_id)
51+
new_entities.append(
52+
LevitonDimmer(client, coordinator, device_id, config_entry.entry_id)
53+
)
54+
if new_entities:
55+
async_add_entities(new_entities)
56+
57+
_add_new_entities()
58+
config_entry.async_on_unload(coordinator.async_add_listener(_add_new_entities))
4859

4960

5061
class LevitonDimmer(LevitonEntity, LightEntity):

custom_components/leviton_smart/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "cloud_push",
1010
"issue_tracker": "https://github.com/simplytoast1/ha-levitonsmart/issues",
1111
"requirements": [],
12-
"version": "1.1.3"
12+
"version": "1.1.4"
1313
}

custom_components/leviton_smart/switch.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from homeassistant.components.switch import SwitchEntity
1313
from homeassistant.config_entries import ConfigEntry
14-
from homeassistant.core import HomeAssistant
14+
from homeassistant.core import HomeAssistant, callback
1515
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1616

1717
from .const import DOMAIN, MODELS_SWITCH, MODELS_OUTLET, MODELS_GFCI
@@ -32,15 +32,26 @@ async def async_setup_entry(
3232
client = hass.data[DOMAIN][config_entry.entry_id]["client"]
3333
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
3434

35-
entities = []
36-
37-
for device_id, device_data in coordinator.data.items():
38-
model = device_data.get("model", "")
39-
# Create switch entities for switches, outlets, and GFCI devices
40-
if model in SWITCH_MODELS:
41-
entities.append(LevitonSwitch(client, coordinator, device_id, config_entry.entry_id))
42-
43-
async_add_entities(entities)
35+
known_ids: set[str] = set()
36+
37+
@callback
38+
def _add_new_entities() -> None:
39+
new_entities = []
40+
for device_id, device_data in coordinator.data.items():
41+
device_id = str(device_id)
42+
if device_id in known_ids:
43+
continue
44+
model = device_data.get("model", "")
45+
if model in SWITCH_MODELS:
46+
known_ids.add(device_id)
47+
new_entities.append(
48+
LevitonSwitch(client, coordinator, device_id, config_entry.entry_id)
49+
)
50+
if new_entities:
51+
async_add_entities(new_entities)
52+
53+
_add_new_entities()
54+
config_entry.async_on_unload(coordinator.async_add_listener(_add_new_entities))
4455

4556

4657
class LevitonSwitch(LevitonEntity, SwitchEntity):

0 commit comments

Comments
 (0)