Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions bundles/org.openhab.binding.dirigera/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Refer to below sections which devices are supported and are covered by `things`
| `dimmable-light` | Light with brightness support | [Lights](#dimmable-lights) | TRÅDFRI |
| `temperature-light` | Light with color temperature support | [Lights](#temperature-lights) | TRÅDFRI, FLOALT |
| `color-light` | Light with color support | [Lights](#color-lights) | TRÅDFRI, ORMANÅS |
| `light-set` | Group of lights controlled as one logical unit | [Lights](#light-set) | Set of lights which are handled together |
| `light-controller` | Controller to handle light attributes | [Controller](#light-controller) | TRÅDFRI, RODRET,STYRBAAR |
| `motion-sensor` | Sensor detecting motion events | [Sensors](#motion-sensor) | TRÅDFRI |
| `motion-light-sensor` | Sensor detecting motion events and measures light level | [Sensors](#motion-light-sensor) | VALLHORN |
Expand Down Expand Up @@ -376,6 +377,38 @@ Channel `color` can receive
- numbers from 0 to 100 as brightness in percent where 0 will switch the light OFF, any other > 0 switches light ON
- triple values for hue, saturation, brightness

## Light Set

A Light Set is a group of individual lights that the IKEA Home smart app manages under a single logical device.
The `light-set` thing maps to this group and provides the same channels as [Color Lights](#color-lights).

| Channel | Type | Read/Write | Description | Advanced |
|---------------------------|-----------------------|------------|------------------------------------------------------|----------|
| `power` | Switch | RW | Power state of the light set | |
| `brightness` | Dimmer | RW | Brightness of the light set in percent | |
| `color-temperature` | Dimmer | RW | Color temperature from cold (0 %) to warm (100 %) | |
| `color-temperature-abs` | Number:Temperature | RW | Color temperature in Kelvin | X |
| `color` | Color | RW | Color with hue, saturation and brightness | |
| `startup` | Number | RW | Startup behavior after power cutoff | |
| `custom-name` | String | RW | Name given in the IKEA Home smart app | |

### Availability (ONLINE / OFFLINE)

The `light-set` thing tracks reachability individually for each member bulb.
It reports **ONLINE** as long as at least one member is reachable.
It goes **OFFLINE** only when every member reports `isReachable=false`.

### Important: Inconsistent State

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.
The `light-set` channels reflect the state reported by whichever member sent the most recent websocket update.
This means the channel values represent the state of one member, not a guaranteed aggregate of all members.

**Sets are designed as a control surface, not a state mirror.**
Use the `light-set` channels to issue uniform commands to the group.
Do not rely on the channel state to accurately reflect what every individual bulb in the set is currently doing.
If per-bulb state accuracy is required, add each bulb as its own `color-light` thing alongside the set.

## Power Plugs

Power plugs in different variants.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* used across the whole binding.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - add device set handling
*/
@NonNullByDefault
public class Constants {
Expand Down Expand Up @@ -59,6 +60,7 @@ public class Constants {
public static final ThingTypeUID THING_TYPE_AIR_QUALITY = new ThingTypeUID(BINDING_ID, "air-quality");
public static final ThingTypeUID THING_TYPE_WATER_SENSOR = new ThingTypeUID(BINDING_ID, "water-sensor");
public static final ThingTypeUID THING_TYPE_BLIND = new ThingTypeUID(BINDING_ID, "blind");
public static final ThingTypeUID THING_TYPE_LIGHT_SET = new ThingTypeUID(BINDING_ID, "light-set");
public static final ThingTypeUID THING_TYPE_UNKNOWN = new ThingTypeUID(BINDING_ID, "unknown");
public static final ThingTypeUID THING_TYPE_NOT_FOUND = new ThingTypeUID(BINDING_ID, "not-found");
public static final ThingTypeUID THING_TYPE_IGNORE = new ThingTypeUID(BINDING_ID, "ignore");
Expand Down Expand Up @@ -91,7 +93,7 @@ public class Constants {
THING_TYPE_MATTER_OCCUPANCY_SENSOR, THING_TYPE_MATTER_LIGHT_SENSOR, THING_TYPE_MATTER_ENVIRONMENT_SENSOR,
THING_TYPE_MATTER_OPEN_CLOSE_SENSOR, THING_TYPE_MATTER_WATER_LEAK_SENSOR,
THING_TYPE_MATTER_2_BUTTON_CONTROLLER, THING_TYPE_MATTER_3_BUTTON_CONTROLLER, THING_TYPE_MATTER_LIGHT,
THING_TYPE_MATTER_OUTLET);
THING_TYPE_MATTER_OUTLET, THING_TYPE_LIGHT_SET);

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

Expand Down Expand Up @@ -264,6 +267,11 @@ public class Constants {
CHANNEL_LIGHT_COLOR, "colorSaturation", CHANNEL_LIGHT_COLOR, "colorTemperature", CHANNEL_LIGHT_TEMPERATURE,
ATTRIBUTES_KEY_STARTUP_BEHAVIOR, CHANNEL_STARTUP_BEHAVIOR);;

public static final Map<String, String> LIGHT_SET_MAP = Map.of(ATTRIBUTES_KEY_CUSTOM_NAME, CHANNEL_CUSTOM_NAME,
ATTRIBUTES_KEY_POWER_STATE, CHANNEL_POWER_STATE, "lightLevel", CHANNEL_LIGHT_BRIGHTNESS, "colorHue",
CHANNEL_LIGHT_COLOR, "colorSaturation", CHANNEL_LIGHT_COLOR, "colorTemperature", CHANNEL_LIGHT_TEMPERATURE,
ATTRIBUTES_KEY_STARTUP_BEHAVIOR, CHANNEL_STARTUP_BEHAVIOR);
Comment thread
weymann marked this conversation as resolved.

public static final Map<String, String> CONTACT_SENSOR_MAP = Map.of(ATTRIBUTES_KEY_CUSTOM_NAME, CHANNEL_CUSTOM_NAME,
"batteryPercentage", CHANNEL_BATTERY_LEVEL, "isOpen", CHANNEL_CONTACT);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.openhab.binding.dirigera.internal.handler.controller.SoundControllerHandler;
import org.openhab.binding.dirigera.internal.handler.light.ColorLightHandler;
import org.openhab.binding.dirigera.internal.handler.light.DimmableLightHandler;
import org.openhab.binding.dirigera.internal.handler.light.LightSetHandler;
import org.openhab.binding.dirigera.internal.handler.light.SwitchLightHandler;
import org.openhab.binding.dirigera.internal.handler.light.TemperatureLightHandler;
import org.openhab.binding.dirigera.internal.handler.matter.Matter2ButtonController;
Expand Down Expand Up @@ -70,6 +71,7 @@
* handlers.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - add device set handling
*/
@NonNullByDefault
@Component(configurationPid = "binding.dirigera", service = ThingHandlerFactory.class)
Expand Down Expand Up @@ -189,6 +191,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return new Matter3ButtonController(thing);
} else if (THING_TYPE_MATTER_LIGHT.equals(thingTypeUID)) {
return new MatterLight(thing, COLOR_LIGHT_MAP, stateProvider);
} else if (THING_TYPE_LIGHT_SET.equals(thingTypeUID)) {
return new LightSetHandler(thing, LIGHT_SET_MAP, stateProvider);
} else {
logger.debug("DIRIGERA FACTORY Request for {} doesn't match {}", thingTypeUID, THING_TYPE_GATEWAY);
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
* {@link BaseHandler} for all devices
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - add device set handling
*/
@NonNullByDefault
public class BaseHandler extends BaseThingHandler implements BaseDevice, DebugHandler {
Expand Down Expand Up @@ -342,6 +343,21 @@ protected int sendAttributes(JSONObject attributes) {
return status;
}

/**
* Wrapper function for device sets - routes to /devices/set/{id} endpoint
*
* @param attributes
* @return status
*/
protected int sendSetAttributes(JSONObject attributes) {
int status = gateway().api().sendSetAttributes(config.id, attributes);
if (customDebug) {
logger.info("DIRIGERA BASE_HANDLER {} API set call: Status {} payload {}", thing.getUID(), status,
attributes);
}
return status;
}

/**
* Wrapper function to respect customDebug flag
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ protected void executeCommand() {
case BRIGHTNESS:
case TEMPERATURE:
case COLOR:
super.sendAttributes(request.request);
sendAttributes(request.request);
if (isPowered()) {
addonMillis = lightConfig.fadeTime;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dirigera.internal.handler.light;

import static org.openhab.binding.dirigera.internal.interfaces.Model.*;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.json.JSONObject;
import org.openhab.binding.dirigera.internal.DirigeraStateDescriptionProvider;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* {@link LightSetHandler} controls a DIRIGERA light set (multiple lights as one logical unit).
* Commands are sent to the hub via the /devices/set/{id} endpoint instead of /devices/{id}.
* All four light capabilities are supported: on/off, brightness, color temperature, and color.
*
* The handler registers under the set ID (config.id) as well as each member device ID so the
* gateway routes websocket updates to handleUpdate for both set-level and member-level events.
* The set is ONLINE if at least one member reports isReachable=true.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - add device set handling
*/
@NonNullByDefault
public class LightSetHandler extends ColorLightHandler {
Comment thread
weymann marked this conversation as resolved.
private final Logger logger = LoggerFactory.getLogger(LightSetHandler.class);

/**
* Tracks per-member reachability: memberId -> isReachable.
* ConcurrentHashMap because handleUpdate() is called from the WebSocket thread
* while initializeDevice() / dispose() run on the openHAB framework thread.
*/
private final Map<String, Boolean> memberReachability = new ConcurrentHashMap<>();
/**
* Member device IDs belonging to this set.
* Wrapped for thread-safety (see memberReachability note above).
*/
private final List<String> memberDeviceIds = Collections.synchronizedList(new ArrayList<>());

public LightSetHandler(Thing thing, Map<String, String> mapping, DirigeraStateDescriptionProvider stateProvider) {
super(thing, mapping, stateProvider);
super.setChildHandler(this);
}

@Override
public void initializeDevice() {
// 1) Get all member device IDs for this set from the model
memberDeviceIds.clear();
memberDeviceIds.addAll(gateway().model().getMemberDeviceIds(config.id));
if (memberDeviceIds.isEmpty()) {
logger.warn("DIRIGERA LIGHT_SET {} no member devices found for set id {}", thing.getLabel(), config.id);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No member devices found for light set");
return;
}
if (customDebug) {
logger.info("DIRIGERA LIGHT_SET {} member devices: {}", thing.getLabel(), memberDeviceIds);
}

// initialize all members as not reachable
memberReachability.clear();
memberDeviceIds.forEach(id -> memberReachability.put(id, false));

updateProperties();

Comment thread
weymann marked this conversation as resolved.
// 2) Initialize the set's own customName from the model so that subsequent member
// updates (which carry the member's own customName) cannot overwrite it.
// getPropertiesFor() returns a map with ATTRIBUTES_KEY_CUSTOM_NAME = set name for light sets.
Object setName = gateway().model().getPropertiesFor(config.id).get(ATTRIBUTES_KEY_CUSTOM_NAME);
if (setName instanceof String nameStr && !nameStr.isBlank()) {
JSONObject nameInit = new JSONObject();
JSONObject attributes = new JSONObject();
attributes.put(ATTRIBUTES_KEY_CUSTOM_NAME, nameStr);
nameInit.put(JSON_KEY_ATTRIBUTES, attributes);
super.handleUpdate(nameInit);
}

// 3) Register under the set ID itself (for future set-level events from the hub)
// and under each member device ID so the gateway routes member websocket updates.
// registerDevice() does not throw checked exceptions; any gateway NPE is prevented
// by the null-guard in BaseHandler.gateway(), so no try-catch is needed here.
gateway().registerDevice(child, config.id);
memberDeviceIds.forEach(memberId -> gateway().registerDevice(child, memberId));

Comment thread
weymann marked this conversation as resolved.
// 4) Poll current state for each reachable member so the handler reaches ONLINE
// immediately without waiting for the first websocket event.
// Only call handleUpdate for reachable members — unreachable ones stay false in
// memberReachability (initialized above) and must not overwrite channel state.
for (String memberId : memberDeviceIds) {
JSONObject deviceState = gateway().api().readDevice(memberId);
if (deviceState.optBoolean(JSON_KEY_REACHABLE, false)) {
handleUpdate(deviceState);
}
}

// 5) If no member reported isReachable=true, go OFFLINE explicitly.
// This covers the case where readDevice returned empty/error for all members.
boolean anyReachable = memberReachability.values().stream().anyMatch(r -> r);
if (!anyReachable) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/dirigera.device.status.not-reachable");
}
}

/**
* Receives websocket updates from all registered member devices.
* Aggregates isReachable across all members: ONLINE if at least one is reachable.
* Delegates attribute updates (brightness, color, etc.) to the parent only when online.
*/
@Override
public void handleUpdate(JSONObject update) {
if (customDebug) {
logger.info("DIRIGERA LIGHT_SET {} handleUpdate {}", thing.getLabel(), update);
}
JSONObject stripped = new JSONObject(update, update.keySet().toArray(new String[0]));

// strip customName for each member update
if (update.has(JSON_KEY_ATTRIBUTES)) {
stripped.getJSONObject(JSON_KEY_ATTRIBUTES).remove(ATTRIBUTES_KEY_CUSTOM_NAME);
}

// handle reachable flag for deviceSet
if (update.has(JSON_KEY_REACHABLE)) {
// identify which member sent this update and track its reachability
String sourceId = update.optString(JSON_KEY_DEVICE_ID, "");
if (memberReachability.containsKey(sourceId)) {
memberReachability.put(sourceId, update.getBoolean(JSON_KEY_REACHABLE));
}

boolean anyReachable = memberReachability.values().stream().anyMatch(r -> r);
if (anyReachable) {
online = true;
updateStatus(ThingStatus.ONLINE);
} else {
online = false;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/dirigera.device.status.not-reachable");
}

// Strip isReachable so the parent handleUpdate does not override our status.
// Shallow copy via keySet() avoids the expensive toString()/parse round-trip.
stripped.remove(JSON_KEY_REACHABLE);
// Also strip customName from member attributes: each member carries its own
// device name, which must not overwrite the set name initialized in initializeDevice().
if (stripped.has(JSON_KEY_ATTRIBUTES)) {
stripped.getJSONObject(JSON_KEY_ATTRIBUTES).remove(ATTRIBUTES_KEY_CUSTOM_NAME);
}
}
super.handleUpdate(stripped);
}

/**
* Unregister from all member device IDs on dispose.
* The set ID (config.id) is unregistered by super.dispose().
*
* Ordering rationale: member IDs are unregistered BEFORE super.dispose() so that
* no further WebSocket events are routed to this handler while BaseHandler tears down.
* super.dispose() is called last to ensure config.id is also unregistered cleanly.
*/
@Override
public void dispose() {
memberDeviceIds.forEach(memberId -> {
try {
gateway().unregisterDevice(child, memberId);
} catch (Exception e) {
logger.debug("DIRIGERA LIGHT_SET {} unregister {} failed: {}", thing.getLabel(), memberId,
e.getMessage());
}
});
Comment thread
weymann marked this conversation as resolved.
memberDeviceIds.clear();
memberReachability.clear();
super.dispose();
}

/**
* Override sendAttributes to route all commands to /devices/set/{id}.
*/
@Override
protected int sendAttributes(JSONObject attributes) {
logger.trace("DIRIGERA LIGHT_SET {} sending set attributes {}", thing.getLabel(), attributes);
return super.sendSetAttributes(attributes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* more updates were received.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - add device set handling
*/
@NonNullByDefault
public interface DirigeraAPI {
Expand Down Expand Up @@ -78,6 +79,15 @@ public interface DirigeraAPI {
*/
int sendAttributes(String deviceId, JSONObject attributes);

/**
* Send attributes to a device set (multiple lights controlled as one unit via /devices/set/{id})
*
* @param setId to update
* @param attributes to send
* @return Integer of http response status
*/
int sendSetAttributes(String setId, JSONObject attributes);

/**
* Send patch with other data than attributes to a device
*
Expand Down
Loading
Loading