Skip to content

Commit 1bd4da1

Browse files
authored
[dirigera] Add device set support (#20691)
* initial contribution Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
1 parent 76aea7c commit 1bd4da1

15 files changed

Lines changed: 767 additions & 4 deletions

File tree

bundles/org.openhab.binding.dirigera/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Refer to below sections which devices are supported and are covered by `things`
1919
| `dimmable-light` | Light with brightness support | [Lights](#dimmable-lights) | TRÅDFRI |
2020
| `temperature-light` | Light with color temperature support | [Lights](#temperature-lights) | TRÅDFRI, FLOALT |
2121
| `color-light` | Light with color support | [Lights](#color-lights) | TRÅDFRI, ORMANÅS |
22+
| `light-set` | Group of lights controlled as one logical unit | [Lights](#light-set) | Set of lights which are handled together |
2223
| `light-controller` | Controller to handle light attributes | [Controller](#light-controller) | TRÅDFRI, RODRET,STYRBAAR |
2324
| `motion-sensor` | Sensor detecting motion events | [Sensors](#motion-sensor) | TRÅDFRI |
2425
| `motion-light-sensor` | Sensor detecting motion events and measures light level | [Sensors](#motion-light-sensor) | VALLHORN |
@@ -376,6 +377,38 @@ Channel `color` can receive
376377
- numbers from 0 to 100 as brightness in percent where 0 will switch the light OFF, any other > 0 switches light ON
377378
- triple values for hue, saturation, brightness
378379

380+
## Light Set
381+
382+
A Light Set is a group of individual lights that the IKEA Home smart app manages under a single logical device.
383+
The `light-set` thing maps to this group and provides the same channels as [Color Lights](#color-lights).
384+
385+
| Channel | Type | Read/Write | Description | Advanced |
386+
|---------------------------|-----------------------|------------|------------------------------------------------------|----------|
387+
| `power` | Switch | RW | Power state of the light set | |
388+
| `brightness` | Dimmer | RW | Brightness of the light set in percent | |
389+
| `color-temperature` | Dimmer | RW | Color temperature from cold (0 %) to warm (100 %) | |
390+
| `color-temperature-abs` | Number:Temperature | RW | Color temperature in Kelvin | X |
391+
| `color` | Color | RW | Color with hue, saturation and brightness | |
392+
| `startup` | Number | RW | Startup behavior after power cutoff | |
393+
| `custom-name` | String | RW | Name given in the IKEA Home smart app | |
394+
395+
### Availability (ONLINE / OFFLINE)
396+
397+
The `light-set` thing tracks reachability individually for each member bulb.
398+
It reports **ONLINE** as long as at least one member is reachable.
399+
It goes **OFFLINE** only when every member reports `isReachable=false`.
400+
401+
### Important: Inconsistent State
402+
403+
Because each member bulb inside a set can be controlled independently, like a physical switch, or another openHAB rule, the actual state of the individual bulbs may diverge from each other.
404+
The `light-set` channels reflect the state reported by whichever member sent the most recent websocket update.
405+
This means the channel values represent the state of one member, not a guaranteed aggregate of all members.
406+
407+
**Sets are designed as a control surface, not a state mirror.**
408+
Use the `light-set` channels to issue uniform commands to the group.
409+
Do not rely on the channel state to accurately reflect what every individual bulb in the set is currently doing.
410+
If per-bulb state accuracy is required, add each bulb as its own `color-light` thing alongside the set.
411+
379412
## Power Plugs
380413

381414
Power plugs in different variants.

bundles/org.openhab.binding.dirigera/src/main/java/org/openhab/binding/dirigera/internal/Constants.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* used across the whole binding.
2828
*
2929
* @author Bernd Weymann - Initial contribution
30+
* @author Bernd Weymann - add device set handling
3031
*/
3132
@NonNullByDefault
3233
public class Constants {
@@ -59,6 +60,7 @@ public class Constants {
5960
public static final ThingTypeUID THING_TYPE_AIR_QUALITY = new ThingTypeUID(BINDING_ID, "air-quality");
6061
public static final ThingTypeUID THING_TYPE_WATER_SENSOR = new ThingTypeUID(BINDING_ID, "water-sensor");
6162
public static final ThingTypeUID THING_TYPE_BLIND = new ThingTypeUID(BINDING_ID, "blind");
63+
public static final ThingTypeUID THING_TYPE_LIGHT_SET = new ThingTypeUID(BINDING_ID, "light-set");
6264
public static final ThingTypeUID THING_TYPE_UNKNOWN = new ThingTypeUID(BINDING_ID, "unknown");
6365
public static final ThingTypeUID THING_TYPE_NOT_FOUND = new ThingTypeUID(BINDING_ID, "not-found");
6466
public static final ThingTypeUID THING_TYPE_IGNORE = new ThingTypeUID(BINDING_ID, "ignore");
@@ -91,7 +93,7 @@ public class Constants {
9193
THING_TYPE_MATTER_OCCUPANCY_SENSOR, THING_TYPE_MATTER_LIGHT_SENSOR, THING_TYPE_MATTER_ENVIRONMENT_SENSOR,
9294
THING_TYPE_MATTER_OPEN_CLOSE_SENSOR, THING_TYPE_MATTER_WATER_LEAK_SENSOR,
9395
THING_TYPE_MATTER_2_BUTTON_CONTROLLER, THING_TYPE_MATTER_3_BUTTON_CONTROLLER, THING_TYPE_MATTER_LIGHT,
94-
THING_TYPE_MATTER_OUTLET);
96+
THING_TYPE_MATTER_OUTLET, THING_TYPE_LIGHT_SET);
9597

9698
// Thing types to be ignored for discovery
9799
public static final Set<ThingTypeUID> IGNORE_THING_TYPES_UIDS = Set.of(THING_TYPE_IGNORE, THING_TYPE_MATTER_UNKNOWN,
@@ -106,6 +108,7 @@ public class Constants {
106108
public static final String TOKEN_URL = BASE_URL + "/oauth/token";
107109
public static final String HOME_URL = BASE_URL + "/home";
108110
public static final String DEVICE_URL = BASE_URL + "/devices/%s";
111+
public static final String DEVICE_SET_URL = BASE_URL + "/devices/set/%s";
109112
public static final String SCENE_URL = BASE_URL + "/scenes/%s";
110113
public static final String SCENES_URL = BASE_URL + "/scenes";
111114

@@ -264,6 +267,11 @@ public class Constants {
264267
CHANNEL_LIGHT_COLOR, "colorSaturation", CHANNEL_LIGHT_COLOR, "colorTemperature", CHANNEL_LIGHT_TEMPERATURE,
265268
ATTRIBUTES_KEY_STARTUP_BEHAVIOR, CHANNEL_STARTUP_BEHAVIOR);;
266269

270+
public static final Map<String, String> LIGHT_SET_MAP = Map.of(ATTRIBUTES_KEY_CUSTOM_NAME, CHANNEL_CUSTOM_NAME,
271+
ATTRIBUTES_KEY_POWER_STATE, CHANNEL_POWER_STATE, "lightLevel", CHANNEL_LIGHT_BRIGHTNESS, "colorHue",
272+
CHANNEL_LIGHT_COLOR, "colorSaturation", CHANNEL_LIGHT_COLOR, "colorTemperature", CHANNEL_LIGHT_TEMPERATURE,
273+
ATTRIBUTES_KEY_STARTUP_BEHAVIOR, CHANNEL_STARTUP_BEHAVIOR);
274+
267275
public static final Map<String, String> CONTACT_SENSOR_MAP = Map.of(ATTRIBUTES_KEY_CUSTOM_NAME, CHANNEL_CUSTOM_NAME,
268276
"batteryPercentage", CHANNEL_BATTERY_LEVEL, "isOpen", CHANNEL_CONTACT);
269277

bundles/org.openhab.binding.dirigera/src/main/java/org/openhab/binding/dirigera/internal/DirigeraHandlerFactory.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.openhab.binding.dirigera.internal.handler.controller.SoundControllerHandler;
3131
import org.openhab.binding.dirigera.internal.handler.light.ColorLightHandler;
3232
import org.openhab.binding.dirigera.internal.handler.light.DimmableLightHandler;
33+
import org.openhab.binding.dirigera.internal.handler.light.LightSetHandler;
3334
import org.openhab.binding.dirigera.internal.handler.light.SwitchLightHandler;
3435
import org.openhab.binding.dirigera.internal.handler.light.TemperatureLightHandler;
3536
import org.openhab.binding.dirigera.internal.handler.matter.Matter2ButtonController;
@@ -70,6 +71,7 @@
7071
* handlers.
7172
*
7273
* @author Bernd Weymann - Initial contribution
74+
* @author Bernd Weymann - add device set handling
7375
*/
7476
@NonNullByDefault
7577
@Component(configurationPid = "binding.dirigera", service = ThingHandlerFactory.class)
@@ -189,6 +191,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
189191
return new Matter3ButtonController(thing);
190192
} else if (THING_TYPE_MATTER_LIGHT.equals(thingTypeUID)) {
191193
return new MatterLight(thing, COLOR_LIGHT_MAP, stateProvider);
194+
} else if (THING_TYPE_LIGHT_SET.equals(thingTypeUID)) {
195+
return new LightSetHandler(thing, LIGHT_SET_MAP, stateProvider);
192196
} else {
193197
logger.debug("DIRIGERA FACTORY Request for {} doesn't match {}", thingTypeUID, THING_TYPE_GATEWAY);
194198
return null;

bundles/org.openhab.binding.dirigera/src/main/java/org/openhab/binding/dirigera/internal/handler/BaseHandler.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
* {@link BaseHandler} for all devices
6969
*
7070
* @author Bernd Weymann - Initial contribution
71+
* @author Bernd Weymann - add device set handling
7172
*/
7273
@NonNullByDefault
7374
public class BaseHandler extends BaseThingHandler implements BaseDevice, DebugHandler {
@@ -342,6 +343,21 @@ protected int sendAttributes(JSONObject attributes) {
342343
return status;
343344
}
344345

346+
/**
347+
* Wrapper function for device sets - routes to /devices/set/{id} endpoint
348+
*
349+
* @param attributes
350+
* @return status
351+
*/
352+
protected int sendSetAttributes(JSONObject attributes) {
353+
int status = gateway().api().sendSetAttributes(config.id, attributes);
354+
if (customDebug) {
355+
logger.info("DIRIGERA BASE_HANDLER {} API set call: Status {} payload {}", thing.getUID(), status,
356+
attributes);
357+
}
358+
return status;
359+
}
360+
345361
/**
346362
* Wrapper function to respect customDebug flag
347363
*

bundles/org.openhab.binding.dirigera/src/main/java/org/openhab/binding/dirigera/internal/handler/light/BaseLight.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ protected void executeCommand() {
167167
case BRIGHTNESS:
168168
case TEMPERATURE:
169169
case COLOR:
170-
super.sendAttributes(request.request);
170+
sendAttributes(request.request);
171171
if (isPowered()) {
172172
addonMillis = lightConfig.fadeTime;
173173
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.dirigera.internal.handler.light;
14+
15+
import static org.openhab.binding.dirigera.internal.interfaces.Model.*;
16+
17+
import java.util.ArrayList;
18+
import java.util.Collections;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.concurrent.ConcurrentHashMap;
22+
23+
import org.eclipse.jdt.annotation.NonNullByDefault;
24+
import org.json.JSONObject;
25+
import org.openhab.binding.dirigera.internal.DirigeraStateDescriptionProvider;
26+
import org.openhab.core.thing.Thing;
27+
import org.openhab.core.thing.ThingStatus;
28+
import org.openhab.core.thing.ThingStatusDetail;
29+
import org.slf4j.Logger;
30+
import org.slf4j.LoggerFactory;
31+
32+
/**
33+
* {@link LightSetHandler} controls a DIRIGERA light set (multiple lights as one logical unit).
34+
* Commands are sent to the hub via the /devices/set/{id} endpoint instead of /devices/{id}.
35+
* All four light capabilities are supported: on/off, brightness, color temperature, and color.
36+
*
37+
* The handler registers under the set ID (config.id) as well as each member device ID so the
38+
* gateway routes websocket updates to handleUpdate for both set-level and member-level events.
39+
* The set is ONLINE if at least one member reports isReachable=true.
40+
*
41+
* @author Bernd Weymann - Initial contribution
42+
* @author Bernd Weymann - add device set handling
43+
*/
44+
@NonNullByDefault
45+
public class LightSetHandler extends ColorLightHandler {
46+
private final Logger logger = LoggerFactory.getLogger(LightSetHandler.class);
47+
48+
/**
49+
* Tracks per-member reachability: memberId -> isReachable.
50+
* ConcurrentHashMap because handleUpdate() is called from the WebSocket thread
51+
* while initializeDevice() / dispose() run on the openHAB framework thread.
52+
*/
53+
private final Map<String, Boolean> memberReachability = new ConcurrentHashMap<>();
54+
/**
55+
* Member device IDs belonging to this set.
56+
* Wrapped for thread-safety (see memberReachability note above).
57+
*/
58+
private final List<String> memberDeviceIds = Collections.synchronizedList(new ArrayList<>());
59+
60+
public LightSetHandler(Thing thing, Map<String, String> mapping, DirigeraStateDescriptionProvider stateProvider) {
61+
super(thing, mapping, stateProvider);
62+
super.setChildHandler(this);
63+
}
64+
65+
@Override
66+
public void initializeDevice() {
67+
// 1) Get all member device IDs for this set from the model
68+
memberDeviceIds.clear();
69+
memberDeviceIds.addAll(gateway().model().getMemberDeviceIds(config.id));
70+
if (memberDeviceIds.isEmpty()) {
71+
logger.warn("DIRIGERA LIGHT_SET {} no member devices found for set id {}", thing.getLabel(), config.id);
72+
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
73+
"No member devices found for light set");
74+
return;
75+
}
76+
if (customDebug) {
77+
logger.info("DIRIGERA LIGHT_SET {} member devices: {}", thing.getLabel(), memberDeviceIds);
78+
}
79+
80+
// initialize all members as not reachable
81+
memberReachability.clear();
82+
memberDeviceIds.forEach(id -> memberReachability.put(id, false));
83+
84+
updateProperties();
85+
86+
// 2) Initialize the set's own customName from the model so that subsequent member
87+
// updates (which carry the member's own customName) cannot overwrite it.
88+
// getPropertiesFor() returns a map with ATTRIBUTES_KEY_CUSTOM_NAME = set name for light sets.
89+
Object setName = gateway().model().getPropertiesFor(config.id).get(ATTRIBUTES_KEY_CUSTOM_NAME);
90+
if (setName instanceof String nameStr && !nameStr.isBlank()) {
91+
JSONObject nameInit = new JSONObject();
92+
JSONObject attributes = new JSONObject();
93+
attributes.put(ATTRIBUTES_KEY_CUSTOM_NAME, nameStr);
94+
nameInit.put(JSON_KEY_ATTRIBUTES, attributes);
95+
super.handleUpdate(nameInit);
96+
}
97+
98+
// 3) Register under the set ID itself (for future set-level events from the hub)
99+
// and under each member device ID so the gateway routes member websocket updates.
100+
// registerDevice() does not throw checked exceptions; any gateway NPE is prevented
101+
// by the null-guard in BaseHandler.gateway(), so no try-catch is needed here.
102+
gateway().registerDevice(child, config.id);
103+
memberDeviceIds.forEach(memberId -> gateway().registerDevice(child, memberId));
104+
105+
// 4) Poll current state for each reachable member so the handler reaches ONLINE
106+
// immediately without waiting for the first websocket event.
107+
// Only call handleUpdate for reachable members — unreachable ones stay false in
108+
// memberReachability (initialized above) and must not overwrite channel state.
109+
for (String memberId : memberDeviceIds) {
110+
JSONObject deviceState = gateway().api().readDevice(memberId);
111+
if (deviceState.optBoolean(JSON_KEY_REACHABLE, false)) {
112+
handleUpdate(deviceState);
113+
}
114+
}
115+
116+
// 5) If no member reported isReachable=true, go OFFLINE explicitly.
117+
// This covers the case where readDevice returned empty/error for all members.
118+
boolean anyReachable = memberReachability.values().stream().anyMatch(r -> r);
119+
if (!anyReachable) {
120+
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
121+
"@text/dirigera.device.status.not-reachable");
122+
}
123+
}
124+
125+
/**
126+
* Receives websocket updates from all registered member devices.
127+
* Aggregates isReachable across all members: ONLINE if at least one is reachable.
128+
* Delegates attribute updates (brightness, color, etc.) to the parent only when online.
129+
*/
130+
@Override
131+
public void handleUpdate(JSONObject update) {
132+
if (customDebug) {
133+
logger.info("DIRIGERA LIGHT_SET {} handleUpdate {}", thing.getLabel(), update);
134+
}
135+
JSONObject stripped = new JSONObject(update, update.keySet().toArray(new String[0]));
136+
137+
// strip customName for each member update
138+
if (update.has(JSON_KEY_ATTRIBUTES)) {
139+
stripped.getJSONObject(JSON_KEY_ATTRIBUTES).remove(ATTRIBUTES_KEY_CUSTOM_NAME);
140+
}
141+
142+
// handle reachable flag for deviceSet
143+
if (update.has(JSON_KEY_REACHABLE)) {
144+
// identify which member sent this update and track its reachability
145+
String sourceId = update.optString(JSON_KEY_DEVICE_ID, "");
146+
if (memberReachability.containsKey(sourceId)) {
147+
memberReachability.put(sourceId, update.getBoolean(JSON_KEY_REACHABLE));
148+
}
149+
150+
boolean anyReachable = memberReachability.values().stream().anyMatch(r -> r);
151+
if (anyReachable) {
152+
online = true;
153+
updateStatus(ThingStatus.ONLINE);
154+
} else {
155+
online = false;
156+
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
157+
"@text/dirigera.device.status.not-reachable");
158+
}
159+
160+
// Strip isReachable so the parent handleUpdate does not override our status.
161+
// Shallow copy via keySet() avoids the expensive toString()/parse round-trip.
162+
stripped.remove(JSON_KEY_REACHABLE);
163+
// Also strip customName from member attributes: each member carries its own
164+
// device name, which must not overwrite the set name initialized in initializeDevice().
165+
if (stripped.has(JSON_KEY_ATTRIBUTES)) {
166+
stripped.getJSONObject(JSON_KEY_ATTRIBUTES).remove(ATTRIBUTES_KEY_CUSTOM_NAME);
167+
}
168+
}
169+
super.handleUpdate(stripped);
170+
}
171+
172+
/**
173+
* Unregister from all member device IDs on dispose.
174+
* The set ID (config.id) is unregistered by super.dispose().
175+
*
176+
* Ordering rationale: member IDs are unregistered BEFORE super.dispose() so that
177+
* no further WebSocket events are routed to this handler while BaseHandler tears down.
178+
* super.dispose() is called last to ensure config.id is also unregistered cleanly.
179+
*/
180+
@Override
181+
public void dispose() {
182+
memberDeviceIds.forEach(memberId -> {
183+
try {
184+
gateway().unregisterDevice(child, memberId);
185+
} catch (Exception e) {
186+
logger.debug("DIRIGERA LIGHT_SET {} unregister {} failed: {}", thing.getLabel(), memberId,
187+
e.getMessage());
188+
}
189+
});
190+
memberDeviceIds.clear();
191+
memberReachability.clear();
192+
super.dispose();
193+
}
194+
195+
/**
196+
* Override sendAttributes to route all commands to /devices/set/{id}.
197+
*/
198+
@Override
199+
protected int sendAttributes(JSONObject attributes) {
200+
logger.trace("DIRIGERA LIGHT_SET {} sending set attributes {}", thing.getLabel(), attributes);
201+
return super.sendSetAttributes(attributes);
202+
}
203+
}

bundles/org.openhab.binding.dirigera/src/main/java/org/openhab/binding/dirigera/internal/interfaces/DirigeraAPI.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
* more updates were received.
2525
*
2626
* @author Bernd Weymann - Initial contribution
27+
* @author Bernd Weymann - add device set handling
2728
*/
2829
@NonNullByDefault
2930
public interface DirigeraAPI {
@@ -78,6 +79,15 @@ public interface DirigeraAPI {
7879
*/
7980
int sendAttributes(String deviceId, JSONObject attributes);
8081

82+
/**
83+
* Send attributes to a device set (multiple lights controlled as one unit via /devices/set/{id})
84+
*
85+
* @param setId to update
86+
* @param attributes to send
87+
* @return Integer of http response status
88+
*/
89+
int sendSetAttributes(String setId, JSONObject attributes);
90+
8191
/**
8292
* Send patch with other data than attributes to a device
8393
*

0 commit comments

Comments
 (0)