Add support for semantic model (#19922)

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
Jacob Laursen
2026-01-24 14:06:06 +01:00
committed by GitHub
parent 148c11c0f3
commit 83e5fb0c10
13 changed files with 646 additions and 116 deletions
+135 -23
View File
@@ -1,44 +1,114 @@
# openHAB Hue Emulation Service
Hue Emulation exposes openHAB items as Hue lights to other Hue API compatible applications like Amazon Echos, Google Homes or any Hue compatible application.
Hue Emulation exposes openHAB items as Philips Hue lights to Hue APIcompatible applications such as Amazon Echo, Google Home, and other Hue-compatible apps.
Because Amazon Echo and Google Home control openHAB locally this way, it is a fast and reliable way to voice control your installation.
Because Amazon Echo and Google Home communicate with openHAB locally via the Hue API, this provides a fast and reliable way to control your installation using voice commands.
See the Troubleshoot section down below though.
This service is independent of the also available Hue binding!
Currently the following Hue functionality is supported:
- Lights: Maps to items
- Groups: Maps to group items
- Rooms: Maps to group items with a specific tag
- Scenes: Maps to rules (new rule engine) that are tagged with "scene"
- Rules: Maps to rules (new rule engine) that are tagged with "hueemulation_rule"
- Schedule: Maps to rules (new rule engine) that are tagged with "hueemulation_schedule"
- **Lights**: Exposed from openHAB items
- **Groups / Rooms**: Derived from openHAB groups or semantic locations
- **Scenes**: Maps to rules that are tagged with `scene`
- **Rules**: Maps to rules that are tagged with `hueemulation_rule`
- **Schedules**: Maps to rules that are tagged with `hueemulation_schedule`
You can create / modify and remove groups, rooms, scenes, rules and schedules from within Hue compatible devices and apps.
You can create, modify, and remove groups, rooms, scenes, rules, and schedules from within Hue-compatible devices and apps.
## Mapping models
Hue Emulation can expose items in two different ways:
1. **Classic item-based mapping** (default)
1. **Semantic model-based mapping** (optional, recommended)
The selected model affects how lights and rooms are derived.
Only one mapping model is active at a time.
## Semantic model mapping
When semantic model mapping is enabled, Hue Emulation derives lights and rooms from openHABs semantic model instead of relying on item types and tags.
This allows Hue-compatible apps to discover a structure that closely matches a real Philips Hue bridge:
- **Lights** are derived from semantic *LightSource* equipment
- **Rooms** are derived from semantic *Location* groups
- **Light capabilities** are inferred from semantic points
### Lights
A Hue light is created for each semantic **LightSource** equipment.
Each LightSource may expose one or more Hue lights, depending on its members:
- Switch → On/Off light
- Dimmer → Dimmable light
- Color → Color light
Only items that are:
- members of a semantic LightSource equipment
- tagged with appropriate semantic Point and Property tags
are considered when deriving Hue lights.
The required semantic tags for each light type are:
- **On/Off lights** (Switch items): Point tag **Switch**, Property tag **Power**
- **Dimmable lights** (Dimmer items): Point tag **Control**, Property tag **Brightness**
- **Color lights** (Color items): Point tag **Control**, Property tag **Color**
Renaming a light will change the label of the LightSource equipment.
### Rooms (Hue groups)
If a **LightSource** is located inside a semantic **Location** group, that location is exposed as a Hue room.
Rooms are optional:
- If no semantic Location exists, lights are still exposed
- Lights without a Location simply appear without a room assignment
### Mixed light types in rooms
If a room contains different kinds of lights (for example switches and color lights), the room exposes the **lowest common denominator** of supported features.
This matches the behavior of a real Hue bridge:
- Commands are broadcast to all lights
- Each light applies only what it supports
- Unsupported attributes are silently ignored
This means, for example, that setting a color on a mixed room will only affect color-capable lights.
Individual lights always retain their full capabilities.
## Discovery
As soon as the service is enabled, it will announce the presence of an (emulated) HUE bridge of the second generation (square bridge).
As soon as the service is enabled, it will announce the presence of an (emulated) Hue bridge of the second generation (square bridge).
Hue bridges are using the Universal Plug and Play (UPnP) protocol for discovery.
![Philips Hue Bridge](doc/Philips_Hue_Bridge.jpg)
Like the real HUE bridge the service must be put into pairing mode before other applications can access it.
Like the real Hue bridge the service must be put into pairing mode before other applications can access it.
By default the pairing mode disables itself after 1 minute (can be configured).
## Exposed lights
## Classic mapping
It is important to note that you are exposing _Items_ not _Things_ or _Channels_.
### Exposed lights
This section applies only when semantic model mapping is disabled.
It is important to note that Hue Emulation exposes _Items_, not _Things_ or _Channels_.
Only Color, Dimmer, Rollershutter, Switch and Group type _Items_ are supported.
Group type items require the "Huelight" tag to be exposed as devices instead of Groups.
This service can emulate 3 different devices:
This service can emulate three different devices:
- An OSRAM SMART+ Plug,
- a dimmable white color Philips A19 bulb and
- an a Philips Gen 3 LCT010 extended color bulb.
- An OSRAM SMART+ Plug
- a dimmable white color Philips A19 bulb
- a Philips Gen 3 LCT010 extended color bulb
The exposed Hue-type depends on some criteria:
@@ -57,16 +127,34 @@ It is the responsibility of binding developers to categories and default tag the
You can tag items manually though as well.
## Exposed names
### Exposed names
This section applies only when semantic model mapping is disabled.
Your items labels are used for exposing!
The default naming schema for automatically linked items unfortunately names _Items_ like their Channel names,
so usually "Brightness" or "Color". You want to rename those.
### Migrating from classic mapping
Existing setups continue to work unchanged.
To migrate to the semantic model:
1. Define semantic LightSource equipment
1. Assign control points (Switch, Dimmer, Color)
1. Optionally place equipment inside Location groups
1. Enable `useSemanticModel`
## Configuration
All options are available in the graphical interface and via textual configuration.
The following configurations can be adjusted.
Semantic model mapping can be enabled via configuration:
```ini
org.openhab.hueemulation:useSemanticModel=true
```
Pairing can be turned on and off:
@@ -203,11 +291,35 @@ It might help to (temporarly) lower the emulated bridge version in the configura
## Text configuration example
The item label will be used as the Hue device name. Please be aware that textual defined items are generally a bad idea.
### Semantic model mapping
The following example shows a semantic model where **Locations** become Hue rooms and **LightSource equipment** becomes Hue lights.
```java
// Locations (Rooms)
Group Kitchen "Kitchen" ["Kitchen"]
Group FamilyRoom "Family room" ["FamilyRoom"]
Group Office "Office" ["Office"]
// LightSource equipment
Group KitchenSpots "Kitchen spots" (Kitchen) ["Lightbulb"]
Group DinnerTableLamp "Dinner table" (FamilyRoom) ["Pendant"]
Group GamingLightStrip "Gaming light strip" (Office) ["LightStrip"]
// Control points
Switch TestSwitch "Switch" (KitchenSpots) ["Switch", "Power"] {channel="..."}
Dimmer TestDimmer "Brightness" (DinnerTableLamp) ["Control", "Brightness"] {channel="..."}
Color TestColor "Color" (GamingLightStrip) ["Control", "Color"] {channel="..."}
```
### Classic mapping
The item label will be used as the Hue device name.
Please be aware that textual defined items are generally a bad idea.
In this case renaming items in Hue compatible Apps will fail.
```java
Switch TestSwitch "Kitchen Switch" [ "Switchable" ] {channel="..."}
Color TestColorBulb "Bathroom" [ "ColorLighting" ] {channel="..."}
Dimmer TestDimmer "Hallway" [ "Lighting" ] {channel="..."}
Switch TestSwitch "Kitchen spots" [ "Switchable" ] {channel="..."}
Dimmer TestDimmer "Dinner table" [ "Lighting" ] {channel="..."}
Color TestColor "Gaming light strip" [ "ColorLighting" ] {channel="..."}
```
@@ -87,6 +87,7 @@ public class ConfigStore {
protected @NonNullByDefault({}) ScheduledExecutorService scheduler;
private @Nullable ScheduledFuture<?> pairingOffFuture;
private @Nullable ScheduledFuture<?> writeUUIDFuture;
private Set<ConfigurationListener> configurationListeners = Collections.synchronizedSet(new LinkedHashSet<>());
/**
* This is the main gson instance, to be obtained by all components that operate on the dto data fields
@@ -97,7 +98,7 @@ public class ConfigStore {
.registerTypeAdapter(HueAuthorizedConfig.class, new HueAuthorizedConfig.Serializer())
.registerTypeAdapter(HueSuccessGeneric.class, new HueSuccessGeneric.Serializer())
.registerTypeAdapter(HueSuccessResponseStateChanged.class, new HueSuccessResponseStateChanged.Serializer())
.registerTypeAdapter(HueGroupEntry.class, new HueGroupEntry.Serializer(this)).create();
.registerTypeAdapter(HueGroupEntry.class, new HueGroupEntry.Serializer()).create();
@Reference
protected @NonNullByDefault({}) ConfigurationAdmin configAdmin;
@@ -115,6 +116,7 @@ public class ConfigStore {
private Set<InetAddress> discoveryIps = Collections.emptySet();
protected volatile @NonNullByDefault({}) HueEmulationConfig config;
public boolean useSemanticModel = false;
public Set<String> switchFilter = Collections.emptySet();
public Set<String> colorFilter = Collections.emptySet();
public Set<String> whiteFilter = Collections.emptySet();
@@ -159,6 +161,14 @@ public class ConfigStore {
}
}
public void addConfigurationListener(ConfigurationListener listener) {
configurationListeners.add(listener);
}
public void removeConfigurationListener(ConfigurationListener listener) {
configurationListeners.remove(listener);
}
private @Nullable InetAddress byName(@Nullable String address) {
if (address == null) {
return null;
@@ -175,6 +185,8 @@ public class ConfigStore {
public void modified(Map<String, Object> properties) {
this.config = new Configuration(properties).as(HueEmulationConfig.class);
useSemanticModel = config.useSemanticModel;
switchFilter = Collections.unmodifiableSet(
Stream.of(config.restrictToTagsSwitches.split(",")).map(String::trim).collect(Collectors.toSet()));
@@ -248,6 +260,10 @@ public class ConfigStore {
if (eventAdmin != null) {
eventAdmin.postEvent(new Event(EVENT_ADDRESS_CHANGED, Collections.emptyMap()));
}
for (ConfigurationListener listener : configurationListeners) {
listener.bindingConfigurationChanged();
}
}
private String getConfiguredHostAddress(InetAddress configuredAddress) {
@@ -0,0 +1,25 @@
/*
* 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.io.hueemulation.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Interface for listening to binding configuration changes.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public interface ConfigurationListener {
void bindingConfigurationChanged();
}
@@ -44,6 +44,7 @@ public class HueEmulationConfig {
*/
public @Nullable String discoveryIp;
public int discoveryHttpPort = 0;
public boolean useSemanticModel = false;
/** Comma separated list of tags */
public String restrictToTagsSwitches = "Switchable";
/** Comma separated list of tags */
@@ -0,0 +1,245 @@
/*
* 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.io.hueemulation.internal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.GenericItem;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.semantics.SemanticTag;
import org.openhab.core.semantics.SemanticTags;
import org.openhab.core.semantics.SemanticsPredicates;
import org.openhab.core.semantics.Tag;
import org.openhab.core.semantics.model.DefaultSemanticTags;
import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment;
import org.openhab.core.semantics.model.DefaultSemanticTags.Location;
import org.openhab.core.semantics.model.DefaultSemanticTags.Point;
import org.openhab.core.semantics.model.DefaultSemanticTags.Property;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
/**
* Builds a Hue-compatible model based on openHAB's semantic model.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class SemanticHueModelBuilder {
private static final Map<SemanticTag, String> LOCATION_TO_ROOM_CLASS = Map.ofEntries( //
Map.entry(Location.LIVING_ROOM, "Living room"), //
Map.entry(Location.KITCHEN, "Kitchen"), //
Map.entry(Location.DINING_ROOM, "Dining"), //
Map.entry(Location.BEDROOM, "Bedroom"), //
Map.entry(Location.BATHROOM, "Bathroom"), //
Map.entry(Location.OFFICE, "Office"), //
Map.entry(Location.CORRIDOR, "Hallway"), //
Map.entry(Location.GARAGE, "Garage"), //
Map.entry(Location.TERRACE, "Terrace"), //
Map.entry(Location.GARDEN, "Garden"), //
Map.entry(Location.DRIVEWAY, "Driveway"), //
Map.entry(Location.CARPORT, "Carport"), //
Map.entry(Location.ATTIC, "Attic"), //
Map.entry(Location.GUEST_ROOM, "Guest room"), //
Map.entry(Location.LAUNDRY_ROOM, "Laundry room"), //
Map.entry(Location.PORCH, "Porch")); //
private final ItemRegistry itemRegistry;
private final ConfigStore configStore;
private final List<HueLightEntry> lights = new ArrayList<>();
private final List<HueGroupEntry> groups = new ArrayList<>();
public SemanticHueModelBuilder(ItemRegistry itemRegistry, ConfigStore configStore) {
this.itemRegistry = itemRegistry;
this.configStore = configStore;
}
public void build() {
Set<GroupItem> lightSources = itemRegistry.getItems().stream().filter(GroupItem.class::isInstance)
.map(GroupItem.class::cast).filter(e -> isSemanticItem(e, Equipment.LIGHT_SOURCE))
.collect(Collectors.toSet());
Map<GroupItem, List<HueLightEntry>> lightSourceToLights = new HashMap<>();
for (GroupItem lightSource : lightSources) {
List<HueLightEntry> lightEntries = createLights(lightSource);
if (!lightEntries.isEmpty()) {
lights.addAll(lightEntries);
lightSourceToLights.put(lightSource, lightEntries);
}
}
Map<GroupItem, List<HueLightEntry>> locationToLights = new HashMap<>();
for (Map.Entry<GroupItem, List<HueLightEntry>> entry : lightSourceToLights.entrySet()) {
GroupItem lightSource = entry.getKey();
GroupItem location = getSemanticGroupItem(lightSource, DefaultSemanticTags.LOCATION);
if (location == null) {
continue;
}
Objects.requireNonNull(locationToLights.computeIfAbsent(location, l -> new ArrayList<>()))
.addAll(entry.getValue());
}
for (Map.Entry<GroupItem, List<HueLightEntry>> entry : locationToLights.entrySet()) {
GroupItem location = entry.getKey();
List<HueLightEntry> locationLights = entry.getValue();
DeviceType groupType = determineGroupDeviceType(locationLights);
HueGroupEntry group = createHueGroup(location, groupType);
group.lights = locationLights.stream().map(light -> configStore.mapItemUIDtoHueID(light.item)).toList();
groups.add(group);
}
}
public List<HueLightEntry> getLights() {
return lights;
}
public List<HueGroupEntry> getGroups() {
return groups;
}
private HueGroupEntry createHueGroup(GroupItem location, DeviceType deviceType) {
String name = location.getLabel();
if (name == null) {
name = location.getName();
}
HueGroupEntry groupEntry = new HueGroupEntry(name, location, deviceType);
groupEntry.type = HueGroupEntry.TypeEnum.Room.name();
groupEntry.roomclass = getRoomClass(location);
return groupEntry;
}
private List<HueLightEntry> createLights(GroupItem lightSource) {
List<HueLightEntry> lightEntries = new ArrayList<>();
for (Item member : lightSource.getMembers()) {
if (!(member instanceof GenericItem genericItem)) {
continue;
}
DeviceType targetType = determineTargetType(configStore, member);
if (targetType == null) {
continue;
}
String hueID = configStore.mapItemUIDtoHueID(member);
String name = lightSource.getLabel();
lightEntries.add(new HueLightEntry(genericItem, configStore.getHueUniqueId(hueID), targetType,
name != null ? name : ""));
}
return lightEntries;
}
private String getRoomClass(GroupItem location) {
for (Map.Entry<SemanticTag, String> entry : LOCATION_TO_ROOM_CLASS.entrySet()) {
Class<? extends Tag> tagClass = SemanticTags.getById(entry.getKey().getUID());
if (tagClass != null && SemanticsPredicates.isA(tagClass).test(location)) {
return entry.getValue();
}
}
return HueGroupEntry.DEFAULT_ROOM_CLASS;
}
private static boolean isSemanticItem(Item item, SemanticTag tag) {
Class<? extends Tag> semanticTag = SemanticTags.getById(tag.getUID());
return semanticTag != null && SemanticsPredicates.isA(semanticTag).test(item);
}
private @Nullable GroupItem getSemanticGroupItem(Item item, SemanticTag tag) {
return SemanticUtils.getSemanticGroupItem(itemRegistry, item, tag);
}
private @Nullable DeviceType determineTargetType(ConfigStore cs, Item item) {
Set<String> tags = item.getTags();
// The user wants this item to be not exposed
if (cs.ignoreItemsFilter.stream().anyMatch(tags::contains)) {
return null;
}
DeviceType deviceType = null;
switch (item.getType()) {
case CoreItemFactory.COLOR:
if (item.hasTag(Point.CONTROL.getName()) && item.hasTag(Property.COLOR.getName())) {
deviceType = DeviceType.ColorType;
break;
}
// Fall through to Dimmer-level behavior
case CoreItemFactory.DIMMER:
if (item.hasTag(Point.CONTROL.getName()) && item.hasTag(Property.BRIGHTNESS.getName())) {
deviceType = DeviceType.WhiteTemperatureType;
break;
}
// Fall through to Switch-level behavior
case CoreItemFactory.SWITCH:
if (item.hasTag(Point.SWITCH.getName()) && item.hasTag(Property.POWER.getName())) {
deviceType = DeviceType.SwitchType;
break;
}
break;
}
if (deviceType == null) {
return null;
}
return hasSemanticGroupItem(item, Equipment.LIGHT_SOURCE) ? deviceType : null;
}
private boolean hasSemanticGroupItem(Item item, SemanticTag tag) {
Class<? extends Tag> semanticRootTag = SemanticTags.getById(tag.getUID());
return semanticRootTag != null && item.getGroupNames().stream().map(itemRegistry::get).filter(Objects::nonNull)
.filter(GroupItem.class::isInstance).map(GroupItem.class::cast)
.anyMatch(SemanticsPredicates.isA(semanticRootTag));
}
private static DeviceType determineGroupDeviceType(List<HueLightEntry> lights) {
return Objects.requireNonNull(lights.stream().map(light -> light.deviceType)
.min(Comparator.comparingInt(SemanticHueModelBuilder::deviceTypeRank)).orElse(DeviceType.SwitchType));
}
private static int deviceTypeRank(DeviceType type) {
return switch (type) {
case SwitchType -> 0;
case WhiteType -> 1;
case WhiteTemperatureType -> 2;
case ColorType -> 3;
};
}
}
@@ -0,0 +1,45 @@
/*
* 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.io.hueemulation.internal;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.semantics.SemanticTag;
import org.openhab.core.semantics.SemanticTags;
import org.openhab.core.semantics.SemanticsPredicates;
import org.openhab.core.semantics.Tag;
/**
* This utility class provides methods for working with items belonging to
* the semantic model.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class SemanticUtils {
public static @Nullable GroupItem getSemanticGroupItem(ItemRegistry itemRegistry, Item item, SemanticTag tag) {
Class<? extends Tag> semanticRootTag = SemanticTags.getById(tag.getUID());
if (semanticRootTag == null) {
return null;
}
return item.getGroupNames().stream().map(itemRegistry::get).filter(Objects::nonNull)
.filter(GroupItem.class::isInstance).map(GroupItem.class::cast)
.filter(SemanticsPredicates.isA(semanticRootTag)).findFirst().orElse(null);
}
}
@@ -62,37 +62,33 @@ public class StateUtils {
AbstractHueState state;
switch (deviceType) {
case ColorType:
if (itemState instanceof HSBType) {
state = new HueStateColorBulb((HSBType) itemState);
} else if (itemState instanceof PercentType) {
state = new HueStateColorBulb((PercentType) itemState, ((PercentType) itemState).intValue() > 0);
} else if (itemState instanceof OnOffType) {
OnOffType t = (OnOffType) itemState;
state = new HueStateColorBulb(t == OnOffType.ON);
if (itemState instanceof HSBType hsbState) {
state = new HueStateColorBulb(hsbState);
} else if (itemState instanceof PercentType percentState) {
state = new HueStateColorBulb(percentState, percentState.intValue() > 0);
} else if (itemState instanceof OnOffType onOffState) {
state = new HueStateColorBulb(onOffState == OnOffType.ON);
} else {
state = new HueStateColorBulb(new HSBType());
}
break;
case WhiteType:
case WhiteTemperatureType:
if (itemState instanceof HSBType) {
PercentType brightness = ((HSBType) itemState).getBrightness();
if (itemState instanceof HSBType hsbState) {
PercentType brightness = hsbState.getBrightness();
state = new HueStateBulb(brightness, brightness.intValue() > 0);
} else if (itemState instanceof PercentType) {
PercentType brightness = (PercentType) itemState;
state = new HueStateBulb(brightness, brightness.intValue() > 0);
} else if (itemState instanceof OnOffType) {
OnOffType t = (OnOffType) itemState;
state = new HueStateBulb(t == OnOffType.ON);
} else if (itemState instanceof PercentType percentState) {
state = new HueStateBulb(percentState, percentState.intValue() > 0);
} else if (itemState instanceof OnOffType onOffState) {
state = new HueStateBulb(onOffState == OnOffType.ON);
} else {
state = new HueStateBulb(new PercentType(0), false);
}
break;
case SwitchType:
default:
if (itemState instanceof OnOffType) {
OnOffType t = (OnOffType) itemState;
state = new HueStatePlug(t == OnOffType.ON);
if (itemState instanceof OnOffType onOffState) {
state = new HueStatePlug(onOffState == OnOffType.ON);
} else {
state = new HueStatePlug(false);
}
@@ -15,12 +15,10 @@ package org.openhab.io.hueemulation.internal.dto;
import java.lang.reflect.Type;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.GroupItem;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.DeviceType;
import com.google.gson.JsonElement;
@@ -44,6 +42,8 @@ public class HueGroupEntry {
Zone // 1.30
}
public static final String DEFAULT_ROOM_CLASS = "Other";
public AbstractHueState action = new HueStatePlug();
// The group type
@@ -53,7 +53,7 @@ public class HueGroupEntry {
public String name;
@SerializedName("class")
public String roomclass = "Other";
public String roomclass = DEFAULT_ROOM_CLASS;
// The IDs of the lights that are in the group.
public List<String> lights = Collections.emptyList();
@@ -77,31 +77,14 @@ public class HueGroupEntry {
groupItem = element;
}
/**
* This custom serializer computes the {@link HueGroupEntry#lights} list, before serializing.
* It does so, by looking up all item members of the references groupItem.
*/
@NonNullByDefault({})
public static class Serializer implements JsonSerializer<HueGroupEntry> {
private ConfigStore cs;
public Serializer(ConfigStore cs) {
this.cs = cs;
}
static class HueGroupHelper extends HueGroupEntry {
}
@Override
public JsonElement serialize(HueGroupEntry product, Type type, JsonSerializationContext context) {
GroupItem item = product.groupItem;
if (item != null) {
product.lights = item.getMembers().stream().map(gitem -> cs.mapItemUIDtoHueID(gitem))
.collect(Collectors.toList());
}
return context.serialize(product, HueGroupHelper.class);
}
}
@@ -52,7 +52,7 @@ public class HueLightEntry {
public String name;
/** Associated item UID */
public @NonNullByDefault({}) transient GenericItem item;
public transient GenericItem item;
public transient DeviceType deviceType;
public transient @Nullable Command lastCommand = null;
public transient @Nullable HueStateChange lastHueChange = null;
@@ -90,13 +90,24 @@ public class HueLightEntry {
* @param deviceType The device type decides which capabilities this device has
*/
public HueLightEntry(GenericItem item, String uniqueid, DeviceType deviceType) {
String label = item.getLabel();
this(item, uniqueid, deviceType, uniqueid);
}
/**
* Create a hue device.
*
* @param item The associated item
* @param uniqueid The unique id
* @param deviceType The device type decides which capabilities this device has
* @param name The name of the device
*/
public HueLightEntry(GenericItem item, String uniqueid, DeviceType deviceType, String name) {
this.item = item;
this.deviceType = deviceType;
this.name = name;
this.uniqueid = uniqueid;
switch (deviceType) {
case ColorType:
this.name = label != null ? label : "";
this.type = "Extended Color light";
this.modelid = "LCT010";
this.colorGamut = "C";
@@ -110,7 +121,6 @@ public class HueLightEntry {
break;
case WhiteType:
/** Hue White A19 - 3nd gen - white, 2700K only */
this.name = label != null ? label : "";
this.type = "Dimmable light";
this.modelid = "LWB006";
this.colorGamut = null;
@@ -123,7 +133,6 @@ public class HueLightEntry {
this.capabilities.certified = true;
break;
case WhiteTemperatureType:
this.name = label != null ? label : "";
this.type = "Color temperature light";
this.modelid = "LTW001";
this.colorGamut = "2200K-6500K";
@@ -141,7 +150,6 @@ public class HueLightEntry {
* Pretend to be an OSRAM plug, there is no native Philips Hue plug on the market.
* Those are supported by most of the external apps and Alexa.
*/
this.name = label != null ? label : "";
this.type = "On/off light";
this.modelid = "Plug 01";
this.colorGamut = null;
@@ -159,7 +167,7 @@ public class HueLightEntry {
}
/**
* This custom serializer updates the light state and label, before serializing.
* This custom serializer updates the light state, before serializing.
*/
@NonNullByDefault({})
public static class Serializer implements JsonSerializer<HueLightEntry> {
@@ -171,10 +179,6 @@ public class HueLightEntry {
public JsonElement serialize(HueLightEntry product, Type type, JsonSerializationContext context) {
product.state = StateUtils.adjustedColorStateFromItemState(product.item.getState(), product.deviceType,
product.lastCommand, product.lastHueChange);
String label = product.item.getLabel();
if (label != null) {
product.name = label;
}
return context.serialize(product, HueDeviceHelper.class);
}
@@ -186,16 +190,13 @@ public class HueLightEntry {
*
* @param element A replace item
*/
public void updateItem(GenericItem element) {
public void updateItem(GenericItem element, String name) {
item = element;
state = StateUtils.colorStateFromItemState(item.getState(), deviceType);
lastCommand = null;
lastHueChange = null;
String label = element.getLabel();
if (label != null) {
name = label;
}
this.name = name;
}
}
@@ -41,11 +41,15 @@ import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.events.ItemEventFactory;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment;
import org.openhab.core.types.Command;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.ConfigurationListener;
import org.openhab.io.hueemulation.internal.DeviceType;
import org.openhab.io.hueemulation.internal.HueEmulationService;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.SemanticHueModelBuilder;
import org.openhab.io.hueemulation.internal.SemanticUtils;
import org.openhab.io.hueemulation.internal.StateUtils;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
@@ -103,7 +107,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
public class LightsAndGroups implements RegistryChangeListener<Item> {
public class LightsAndGroups implements ConfigurationListener, RegistryChangeListener<Item> {
public static final String EXPOSE_AS_DEVICE_TAG = "huelight";
private final Logger logger = LoggerFactory.getLogger(LightsAndGroups.class);
private static final String ITEM_TYPE_GROUP = "Group";
@@ -120,34 +124,81 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
protected volatile @Nullable EventPublisher eventPublisher;
/**
* Registers to the {@link ItemRegistry} and enumerates currently existing items.
* Registers to the {@link ItemRegistry} and {@link ConfigStore} and enumerates currently existing items.
*/
@Activate
protected void activate() {
cs.ds.resetGroupsAndLights();
itemRegistry.removeRegistryChangeListener(this);
cs.addConfigurationListener(this);
itemRegistry.addRegistryChangeListener(this);
for (Item item : itemRegistry.getItems()) {
added(item);
}
rebuildModel();
}
/**
* Unregisters from the {@link ItemRegistry}.
* Unregisters from the {@link ItemRegistry} and {@link ConfigStore}.
*/
@Deactivate
protected void deactivate() {
itemRegistry.removeRegistryChangeListener(this);
cs.removeConfigurationListener(this);
}
public void bindingConfigurationChanged() {
rebuildModel();
}
private void rebuildModel() {
if (cs.useSemanticModel) {
rebuildSemanticModel();
} else {
cs.ds.resetGroupsAndLights();
for (Item item : itemRegistry.getItems()) {
added(item);
}
}
}
private synchronized void rebuildSemanticModel() {
logger.debug("Rebuilding semantic model");
cs.ds.resetGroupsAndLights();
SemanticHueModelBuilder builder = new SemanticHueModelBuilder(itemRegistry, cs);
builder.build();
List<HueLightEntry> lights = builder.getLights();
for (HueLightEntry light : lights) {
String hueID = cs.mapItemUIDtoHueID(light.item);
cs.ds.lights.put(hueID, light);
}
List<HueGroupEntry> groups = builder.getGroups();
for (HueGroupEntry group : groups) {
String hueID = cs.mapItemUIDtoHueID(group.groupItem);
cs.ds.groups.put(hueID, group);
}
updateGroup0();
logger.debug("Semantic model built: {} lights, {} groups", lights.size(), groups.size());
}
@Override
public synchronized void added(Item newElement) {
public void added(Item newElement) {
if (!(newElement instanceof GenericItem element)) {
return;
}
if (cs.useSemanticModel) {
rebuildSemanticModel();
} else {
addedNonSemanticModel(element);
}
}
private synchronized void addedNonSemanticModel(GenericItem element) {
if (!(element instanceof GroupItem) && !ALLOWED_ITEM_TYPES.contains(element.getType())) {
return;
}
@@ -159,12 +210,11 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
String hueID = cs.mapItemUIDtoHueID(element);
if (element instanceof GroupItem && !element.hasTag(EXPOSE_AS_DEVICE_TAG)) {
GroupItem g = (GroupItem) element;
HueGroupEntry group = new HueGroupEntry(g.getName(), g, deviceType);
if (element instanceof GroupItem groupItem && !element.hasTag(EXPOSE_AS_DEVICE_TAG)) {
HueGroupEntry group = new HueGroupEntry(groupItem.getName(), groupItem, deviceType);
// Restore group type and room class from tags
for (String tag : g.getTags()) {
for (String tag : groupItem.getTags()) {
if (tag.startsWith("huetype_")) {
group.type = tag.split("huetype_")[1];
} else if (tag.startsWith("hueroom_")) {
@@ -174,14 +224,16 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
// Add group members
group.lights = new ArrayList<>();
for (Item item : g.getMembers()) {
for (Item item : groupItem.getMembers()) {
group.lights.add(cs.mapItemUIDtoHueID(item));
}
cs.ds.groups.put(hueID, group);
} else {
HueLightEntry device = new HueLightEntry(element, cs.getHueUniqueId(hueID), deviceType);
device.item = element;
String name = element.getLabel();
HueLightEntry device = new HueLightEntry(element, cs.getHueUniqueId(hueID), deviceType,
name != null ? name : "");
cs.ds.lights.put(hueID, device);
updateGroup0();
}
@@ -198,7 +250,15 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
}
@Override
public synchronized void removed(Item element) {
public void removed(Item element) {
if (cs.useSemanticModel) {
rebuildSemanticModel();
} else {
removedNonSemanticModel(element);
}
}
private synchronized void removedNonSemanticModel(Item element) {
String hueID = cs.mapItemUIDtoHueID(element);
logger.debug("Remove item {}", hueID);
cs.ds.lights.remove(hueID);
@@ -210,17 +270,25 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
* The tags might have changed
*/
@Override
public synchronized void updated(Item oldElement, Item newElement) {
if (!(newElement instanceof GenericItem element)) {
public void updated(Item oldElement, Item newElement) {
if (!(newElement instanceof GenericItem newitem)) {
return;
}
String hueID = cs.mapItemUIDtoHueID(element);
if (cs.useSemanticModel) {
rebuildSemanticModel();
} else {
updatedNonSemanticModel(newitem);
}
}
private synchronized void updatedNonSemanticModel(GenericItem newItem) {
String hueID = cs.mapItemUIDtoHueID(newItem);
HueGroupEntry hueGroup = cs.ds.groups.get(hueID);
if (hueGroup != null) {
DeviceType t = StateUtils.determineTargetType(cs, element);
if (t != null && element instanceof GroupItem groupElement) {
DeviceType t = StateUtils.determineTargetType(cs, newItem);
if (t != null && newItem instanceof GroupItem groupElement) {
hueGroup.updateItem(groupElement);
} else {
cs.ds.groups.remove(hueID);
@@ -230,19 +298,20 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
HueLightEntry hueDevice = cs.ds.lights.get(hueID);
if (hueDevice == null) {
// If the correct tags got added -> use the logic within added()
added(element);
added(newItem);
return;
}
// Check if type can still be determined (tags and category is still sufficient)
// and that it's still an allowed item type
DeviceType t = StateUtils.determineTargetType(cs, element);
if (t == null || !ALLOWED_ITEM_TYPES.contains(element.getType())) {
removed(element);
DeviceType t = StateUtils.determineTargetType(cs, newItem);
if (t == null || !ALLOWED_ITEM_TYPES.contains(newItem.getType())) {
removed(newItem);
return;
}
hueDevice.updateItem(element);
String name = newItem.getLabel();
hueDevice.updateItem(newItem, name != null ? name : "");
}
@GET
@@ -341,8 +410,19 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON, "Invalid request: No name set");
}
hueDevice.item.setLabel(name);
itemRegistry.update(hueDevice.item);
GenericItem itemToUpdate;
if (cs.useSemanticModel) {
itemToUpdate = SemanticUtils.getSemanticGroupItem(itemRegistry, hueDevice.item, Equipment.LIGHT_SOURCE);
if (itemToUpdate == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE,
"Light does not belong to a semantic LightSource equipment");
}
} else {
itemToUpdate = hueDevice.item;
}
itemToUpdate.setLabel(name);
itemRegistry.update(itemToUpdate);
return NetworkUtils.singleSuccess(cs.gson, name, "/lights/" + id + "/name");
}
@@ -469,6 +549,11 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
if (cs.useSemanticModel) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.METHOD_NOT_ALLOWED,
"Invalid request: Cannot create groups in semantic model");
}
HueGroupEntry state = cs.gson.fromJson(body, HueGroupEntry.class);
if (state == null || state.name.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
@@ -514,6 +599,11 @@ public class LightsAndGroups implements RegistryChangeListener<Item> {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
if (cs.useSemanticModel) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.METHOD_NOT_ALLOWED,
"Invalid request: Cannot delete groups in semantic model");
}
HueGroupEntry hueDevice = cs.ds.groups.get(id);
if (hueDevice == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");
@@ -35,21 +35,32 @@
want the old (round Hue bridge) to be emulated.</description>
<default>false</default>
</parameter>
<parameter name="useSemanticModel" type="boolean">
<label>Use Semantic Model</label>
<description>If enabled, the Hue emulation will use the semantic model to determine which items to expose. Items
belonging to equipment classified as LightSource are exposed as Hue lights. Light capabilities are derived from
semantic points: Switch items with the 'Switch'/'Power' tags are exposed as on/off lights, Color items with the
'Control'/'Color' tags are exposed as color lights, and Dimmer items with the 'Control'/'Brightness' tags are
exposed as dimmable white lights. If a LightSource is located within a semantic Location, that Location is exposed
as a Hue room. Lights without an associated Location are still exposed but are not assigned to a room.
</description>
<default>false</default>
</parameter>
<parameter name="restrictToTagsSwitches" type="text" required="false">
<label>Switch Item Tags</label>
<description>The HUE emulation can either publish Switch items if this is set to an empty string or filter items by
<description>The Hue emulation can either publish Switch items if this is set to an empty string or filter items by
tags. Use commas to separate multiple entries.</description>
<default>Switchable</default>
</parameter>
<parameter name="restrictToTagsColorLights" type="text" required="false">
<label>Color Item Tags</label>
<description>The HUE emulation can either publish all Color items if this is set to an empty string or filter items
<description>The Hue emulation can either publish all Color items if this is set to an empty string or filter items
by tags. Use commas to separate multiple entries.</description>
<default>ColorLighting</default>
</parameter>
<parameter name="restrictToTagsWhiteLights" type="text" required="false">
<label>White Item Tags</label>
<description>The HUE emulation can either publish all Dimmer items if this is set to an empty string or filter items
<description>The Hue emulation can either publish all Dimmer items if this is set to an empty string or filter items
by tags. Use commas to separate multiple entries.</description>
<default>Lighting</default>
</parameter>
@@ -61,7 +72,7 @@
</parameter>
<parameter name="discoveryIp" type="text" required="false">
<label>Optional Discovery Address</label>
<description>If your host has multiple IP addresses you may specify the IP(s) you would like to advertise in the UPNP
<description>If your host has multiple IP addresses you may specify the IP(s) you would like to advertise in the UPnP
discovery process. You may safely leave this empty on most systems. Use commas to separate multiple entries.</description>
</parameter>
<parameter name="discoveryHttpPort" type="integer" required="false">
@@ -3,12 +3,14 @@
addon.hueemulation.name = Hue Emulation
addon.hueemulation.description = Exposes openHAB as Hue Devices.
# add-on config
io.config.hueemulation.createNewUserOnEveryEndpoint.label = Pairing: Add Unknown User-keys
io.config.hueemulation.createNewUserOnEveryEndpoint.description = Set this option to create new users on the fly during the next pairing mode period. This helps with Amazon Echo device discovery. This option is automatically switched off after the timeout.
io.config.hueemulation.discoveryHttpPort.label = Optional Discovery Web Port
io.config.hueemulation.discoveryHttpPort.description = Some Hue applications require a different port (80) then what openHAB runs on by default (8080). This option will only advertise a different port then what we are listening on. Useful if you have an iptables rule redirect traffic from this port to the openHAB port.
io.config.hueemulation.discoveryIp.label = Optional Discovery Address
io.config.hueemulation.discoveryIp.description = If your host has multiple IP addresses you may specify the IP(s) you would like to advertise in the UPNP discovery process. You may safely leave this empty on most systems. Use commas to separate multiple entries.
io.config.hueemulation.discoveryIp.description = If your host has multiple IP addresses you may specify the IP(s) you would like to advertise in the UPnP discovery process. You may safely leave this empty on most systems. Use commas to separate multiple entries.
io.config.hueemulation.ignoreItemsWithTags.label = Ignore Items by Tag
io.config.hueemulation.ignoreItemsWithTags.description = All items that are tagged with the given tags are ignore by the Hue Emulation Service. Use commas to separate multiple entries.
io.config.hueemulation.pairingEnabled.label = Device Pairing
@@ -18,13 +20,15 @@ io.config.hueemulation.pairingTimeout.description = Pairing is automatically dis
io.config.hueemulation.permanentV1bridge.label = Permanently Emulate V1 Hue Bridge
io.config.hueemulation.permanentV1bridge.description = There is no obvious reason to not emulate the newer bridge all the time, but here is the option if you want the old (round Hue bridge) to be emulated.
io.config.hueemulation.restrictToTagsColorLights.label = Color Item Tags
io.config.hueemulation.restrictToTagsColorLights.description = The HUE emulation can either publish all Color items if this is set to an empty string or filter items by tags. Use commas to separate multiple entries.
io.config.hueemulation.restrictToTagsColorLights.description = The Hue emulation can either publish all Color items if this is set to an empty string or filter items by tags. Use commas to separate multiple entries.
io.config.hueemulation.restrictToTagsSwitches.label = Switch Item Tags
io.config.hueemulation.restrictToTagsSwitches.description = The HUE emulation can either publish Switch items if this is set to an empty string or filter items by tags. Use commas to separate multiple entries.
io.config.hueemulation.restrictToTagsSwitches.description = The Hue emulation can either publish Switch items if this is set to an empty string or filter items by tags. Use commas to separate multiple entries.
io.config.hueemulation.restrictToTagsWhiteLights.label = White Item Tags
io.config.hueemulation.restrictToTagsWhiteLights.description = The HUE emulation can either publish all Dimmer items if this is set to an empty string or filter items by tags. Use commas to separate multiple entries.
io.config.hueemulation.restrictToTagsWhiteLights.description = The Hue emulation can either publish all Dimmer items if this is set to an empty string or filter items by tags. Use commas to separate multiple entries.
io.config.hueemulation.temporarilyEmulateV1bridge.label = Pairing: Temporarily Emulate V1 Hue Bridge
io.config.hueemulation.temporarilyEmulateV1bridge.description = Some Amazon Echos only support V1 bridges (round hardware bridge). This option is only active during discovery and automatically switched off after the timeout.
io.config.hueemulation.useSemanticModel.label = Use Semantic Model
io.config.hueemulation.useSemanticModel.description = If enabled, the Hue emulation will use the semantic model to determine which items to expose. Items belonging to equipment classified as LightSource are exposed as Hue lights. Light capabilities are derived from semantic points: Switch items with the 'Switch'/'Power' tags are exposed as on/off lights, Color items with the 'Control'/'Color' tags are exposed as color lights, and Dimmer items with the 'Control'/'Brightness' tags are exposed as dimmable white lights. If a LightSource is located within a semantic Location, that Location is exposed as a Hue room. Lights without an associated Location are still exposed but are not assigned to a room.
io.config.hueemulation.uuid.label = Unique Bridge ID
io.config.hueemulation.uuid.description = Each Hue bridge has a universal unique id (UUID) assigned. This is random generated if no value has been assigned. Note on Amazon Alexa Echo devices: It might help to change the UUID after you have changed item ids. The Echos will recognize this service as a new bridge.
@@ -128,6 +128,7 @@ public class CommonSetup {
cs = new ConfigStoreWithoutMetadata(networkAddressService, configAdmin, scheduler);
}
cs.activate(Map.of("uuid", "a668dc9b-7172-49c3-832f-acb07dda2a20"));
cs.useSemanticModel = false;
cs.switchFilter = Set.of("Switchable");
cs.whiteFilter = Set.of("Switchable");
cs.colorFilter = Set.of("ColorLighting");