added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.io.hueemulation</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@@ -0,0 +1,198 @@
# 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.
Because Amazon Echo and Google Home control openHAB locally this way, it is a fast and reliable way to voice control your installation.
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"
You can create / modify and remove groups, rooms, scenes, rules and schedules from within Hue compatible devices and apps.
## Discovery:
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.
By default the pairing mode disables itself after 1 minute (can be configured).
## Exposed lights
It is important to note that you are exposing *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:
* An OSRAM SMART+ Plug,
* a dimmable white color Philips A19 bulb and
* an a Philips Gen 3 LCT010 extended color bulb.
The exposed Hue-type depends on some criteria:
* If the item has the category "ColorLight": It will be exposed as a color bulb
* If the item has the category "Light": It will be exposed as a switch.
This initial type determination is overridden if the item is tagged.
The following default tags are setup:
* "Switchable": Item will be exposed as an OSRAM SMART+ Plug
* "Lighting": Item will be exposed as a dimmable white bulb
* "ColorLighting": Item will be exposed as a color bulb
It is the responsibility of binding developers to categories and default tag their available *Channels*, so that linked Items are automatically exposed with this service.
You can tag items manually though as well.
## Exposed names
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.
## Configuration
All options are available in the graphical interface and via textual configuration.
The following configurations can be adjusted.
Pairing can be turned on and off:
```
org.openhab.hueemulation:pairingEnabled=false
```
You can define a pairing timeout in seconds.
After that timeout, the `pairingEnabled` is automatically set to `false`.
```
org.openhab.hueemulation:pairingTimeout=60
```
Enable the following option in combination with pairing to create a new API key on the fly on every API endpoint, not only via the new-user-create API.
Necessary for Amazon Echos and other devices where the API key cannot be reset.
After a new installation of openHAB or a configuration pruning the old API keys are gone but Amazon Echos will keep trying with their old, invalid keys.
```
org.openhab.hueemulation:createNewUserOnEveryEndpoint=false
```
Some Amazon Echo versions only allow V1 Hue bridges (the round ones, not the square ones) to be discovered.
If the following option is enabled in combination with the pairing mode, the service will pretend to be an old Hue bridge.
This option resets automatically after pairing mode has been switched off by the timeout.
```
org.openhab.hueemulation:temporarilyEmulateV1bridge=false
```
Permanent V1 bridge emulation (no obvious reason to enable that):
```
org.openhab.hueemulation:permanentV1bridge=false
```
The hue emulation service will announce its existence via UPnP on every
of the openHAB configured primary addresses (IPv4 and IPv6).
Usually you do not want to set this option, but change the primary address configuration of openHAB.
This option allows you to override what addresses are used for the announcement.
You can have multiple comma separated entries.
```
org.openhab.hueemulation:discoveryIp=192.168.1.100,::FFFF:A9DB:0D85
```
The hue emulation service supports three types of emulated bulbs.
You need to tell the service which item tag corresponds to which emulated bulb type.
One of the comma separated tags must match for the item to be exposed.
Can be empty to match an item based on other criteria.
```
org.openhab.hueemulation:restrictToTagsSwitches=Switchable
org.openhab.hueemulation:restrictToTagsWhiteLights=Lighting
org.openhab.hueemulation:restrictToTagsColorLights=ColorLighting
```
The above default assignment means that every item that has the tag "Switchable" will be emulated as a Zigbee Switch.
If you want your switches to be exposed as lights instead (because your Amazon Echo does not support switches for example), you want to have:
```
org.openhab.hueemulation:restrictToTagsSwitches=NONE
org.openhab.hueemulation:restrictToTagsWhiteLights=Lighting,Switchable
org.openhab.hueemulation:restrictToTagsColorLights=ColorLighting
```
The service tries to expose as much items as possible (greedy), based on some criteria as explained in the section above.
If you want to exclude items, you need to tag them. Define the tags with the following option:
```
org.openhab.hueemulation:ignoreItemsWithTags=internal
```
The default is to not expose any items that have the "internal" tag assigned.
You want this tag for all items that are purely used for rules, as proxy items etc.
## Troubleshooting
Some devices like Amazon Echo, Google Home and all Philips devices (TVs, Apps) expect a Hue bridge to run on port 80.
You must either
* port forward your openHAB installation to port 80,
(`iptables -t nat -A PREROUTING -p tcp -m tcp --dport 80 -j REDIRECT --to-ports 8080`)
* install a reverse proxy on port 80, for example nginx with the following configuration:
```
server {
listen 80;
proxy_pass http://localhost:8080/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
* or let openHAB run on port 80 (the entire java process requires elevated privileges).
Please open port 80 tcp and port 1900 udp in your firewall installation.
You can test if the hue emulation does its job by enabling pairing mode including the option "Amazon Echo device discovery fix".
1. Navigate with your browser to "http://your-openhab-ip/description.xml" to check the discovery response.
Check the IP address in there.
2. Navigate with your browser to "http://your-openhab-ip/api/status" to check the self test report.
If you use the port forwarding way, the self-test page will not be able to correctly determine if your installation works on port 80.
A reverse proxy is recommended.
Depending on the firmware version of your Amazon Echo, it may not support colored bulbs or switches.
Please assign "ColorLighting" and "Switchable" to the `WhiteLights` type as explained above.
Also note that Amazon Echos are stubborn as.
You might need to remove all former recognized devices multiple times and perform the search via different Echos and also the web or mobile application.
It might help to (temporarly) lower the emulated bridge version in the configuration as described above,
## 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.
In this case renaming items in Hue compatible Apps will fail.
```
Switch TestSwitch "Kitchen Switch" [ "Switchable" ] {channel="..."}
Color TestColorBulb "Bathroom" [ "ColorLighting" ] {channel="..."}
Dimmer TestDimmer "Hallway" [ "Lighting" ] {channel="..."}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.io.hueemulation</artifactId>
<name>openHAB Add-ons :: Bundles :: IO :: Hue Emulation Service</name>
<properties>
<bnd.importpackage>org.glassfish.jersey.*;resolution:="optional"</bnd.importpackage>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/org.glassfish.grizzly/grizzly-http-server -->
<dependency>
<groupId>org.glassfish.grizzly</groupId>
<artifactId>grizzly-http-server</artifactId>
<version>2.4.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.grizzly.osgi</groupId>
<artifactId>grizzly-httpservice</artifactId>
<version>2.4.4</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-grizzly2-http</artifactId>
<version>2.28</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>2.28</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jupnp</groupId>
<artifactId>org.jupnp</artifactId>
<version>2.5.2</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.io.hueemulation-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-misc-hueemulation" description="Hue Emulation" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-upnp</feature>
<feature>openhab-core-automation</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.io.hueemulation/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2020 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.glassfish.jersey.servlet.init;
import java.util.Set;
import javax.servlet.ServletContainerInitializer;
import javax.servlet.ServletContext;
/**
* Shut up pax-web, which scans for this initializer class and loudly complains if it can't be found.
*
* @author David Graeff - Initial contribution
*/
public class JerseyServletContainerInitializer implements ServletContainerInitializer {
@Override
public void onStartup(Set<Class<?>> c, ServletContext ctx) {
}
}

View File

@@ -0,0 +1,373 @@
/**
* Copyright (c) 2010-2020 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.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.ConfigurableService;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.items.Item;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.net.CidrAddress;
import org.openhab.core.net.NetUtil;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.io.hueemulation.internal.dto.HueAuthorizedConfig;
import org.openhab.io.hueemulation.internal.dto.HueDataStore;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry;
import org.openhab.io.hueemulation.internal.dto.HueSensorEntry;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseStateChanged;
import org.osgi.service.cm.ConfigurationAdmin;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* This component sets up the hue data store and gets the service configuration.
* It also determines the address for the upnp service by the given configuration.
* <p>
* Also manages the pairing timeout. The service is restarted after a pairing timeout, due to the ConfigAdmin
* configuration change.
* <p>
* This is a central component and required by all other components and may not
* depend on anything in this bundle.
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { ConfigStore.class }, configurationPid = {
HueEmulationService.CONFIG_PID }, property = { "com.eclipsesource.jaxrs.publish=false",
ConfigurableService.SERVICE_PROPERTY_DESCRIPTION_URI + "=io:hueemulation",
ConfigurableService.SERVICE_PROPERTY_CATEGORY + "=io",
ConfigurableService.SERVICE_PROPERTY_LABEL + "=Hue Emulation" })
@NonNullByDefault
public class ConfigStore {
public static final String METAKEY = "HUEEMU";
public static final String EVENT_ADDRESS_CHANGED = "ESH_EMU_CONFIG_ADDR_CHANGED";
private final Logger logger = LoggerFactory.getLogger(ConfigStore.class);
public HueDataStore ds = new HueDataStore();
protected @NonNullByDefault({}) ScheduledExecutorService scheduler;
private @Nullable ScheduledFuture<?> pairingOffFuture;
private @Nullable ScheduledFuture<?> writeUUIDFuture;
/**
* This is the main gson instance, to be obtained by all components that operate on the dto data fields
*/
public final Gson gson = new GsonBuilder().registerTypeAdapter(HueLightEntry.class, new HueLightEntry.Serializer())
.registerTypeAdapter(HueSensorEntry.class, new HueSensorEntry.Serializer())
.registerTypeAdapter(HueRuleEntry.Condition.class, new HueRuleEntry.SerializerCondition())
.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();
@Reference
protected @NonNullByDefault({}) ConfigurationAdmin configAdmin;
@Reference
protected @NonNullByDefault({}) NetworkAddressService networkAddressService;
@Reference
protected @NonNullByDefault({}) MetadataRegistry metadataRegistry;
@Reference
protected @NonNullByDefault({}) EventAdmin eventAdmin;
//// objects, set within activate()
private Set<InetAddress> discoveryIps = Collections.emptySet();
protected volatile @NonNullByDefault({}) HueEmulationConfig config;
public Set<String> switchFilter = Collections.emptySet();
public Set<String> colorFilter = Collections.emptySet();
public Set<String> whiteFilter = Collections.emptySet();
public Set<String> ignoreItemsFilter = Collections.emptySet();
private int highestAssignedHueID = 1;
public ConfigStore() {
scheduler = ThreadPoolManager.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
}
/**
* For test dependency injection
*
* @param networkAddressService The network address service
* @param configAdmin The configuration admin service
* @param metadataRegistry The metadataRegistry service
*/
public ConfigStore(NetworkAddressService networkAddressService, ConfigurationAdmin configAdmin,
@Nullable MetadataRegistry metadataRegistry, ScheduledExecutorService scheduler) {
this.networkAddressService = networkAddressService;
this.configAdmin = configAdmin;
this.metadataRegistry = metadataRegistry;
this.scheduler = scheduler;
}
@Activate
public void activate(Map<String, Object> properties) {
this.config = new Configuration(properties).as(HueEmulationConfig.class);
determineHighestAssignedHueID();
if (config.uuid.isEmpty()) {
config.uuid = UUID.randomUUID().toString();
writeUUIDFuture = scheduler.schedule(() -> {
logger.info("No unique ID assigned yet. Assigning {} and restarting...", config.uuid);
WriteConfig.setUUID(configAdmin, config.uuid);
}, 100, TimeUnit.MILLISECONDS);
return;
} else {
modified(properties);
}
}
private @Nullable InetAddress byName(@Nullable String address) {
if (address == null) {
return null;
}
try {
return InetAddress.getByName(address);
} catch (UnknownHostException e) {
logger.warn("Given IP address could not be resolved: {}", address, e);
return null;
}
}
@Modified
public void modified(Map<String, Object> properties) {
this.config = new Configuration(properties).as(HueEmulationConfig.class);
switchFilter = Collections.unmodifiableSet(
Stream.of(config.restrictToTagsSwitches.split(",")).map(String::trim).collect(Collectors.toSet()));
colorFilter = Collections.unmodifiableSet(
Stream.of(config.restrictToTagsColorLights.split(",")).map(String::trim).collect(Collectors.toSet()));
whiteFilter = Collections.unmodifiableSet(
Stream.of(config.restrictToTagsWhiteLights.split(",")).map(String::trim).collect(Collectors.toSet()));
ignoreItemsFilter = Collections.unmodifiableSet(
Stream.of(config.ignoreItemsWithTags.split(",")).map(String::trim).collect(Collectors.toSet()));
// Use either the user configured
InetAddress configuredAddress = null;
int networkPrefixLength = 24; // Default for most networks: 255.255.255.0
if (config.discoveryIp != null) {
discoveryIps = Collections.unmodifiableSet(Stream.of(config.discoveryIp.split(",")).map(String::trim)
.map(this::byName).filter(e -> e != null).collect(Collectors.toSet()));
} else {
discoveryIps = new LinkedHashSet<>();
configuredAddress = byName(networkAddressService.getPrimaryIpv4HostAddress());
if (configuredAddress != null) {
discoveryIps.add(configuredAddress);
}
for (CidrAddress a : NetUtil.getAllInterfaceAddresses()) {
if (a.getAddress().equals(configuredAddress)) {
networkPrefixLength = a.getPrefix();
} else {
discoveryIps.add(a.getAddress());
}
}
}
if (discoveryIps.isEmpty()) {
try {
logger.info("No discovery ip specified. Trying to determine the host address");
configuredAddress = InetAddress.getLocalHost();
} catch (Exception e) {
logger.info("Host address cannot be determined. Trying loopback address");
configuredAddress = InetAddress.getLoopbackAddress();
}
} else {
configuredAddress = discoveryIps.iterator().next();
}
logger.info("Using discovery ip {}", configuredAddress.getHostAddress());
// Get and apply configurations
ds.config.createNewUserOnEveryEndpoint = config.createNewUserOnEveryEndpoint;
ds.config.networkopenduration = config.pairingTimeout;
ds.config.devicename = config.devicename;
ds.config.uuid = config.uuid;
ds.config.bridgeid = config.uuid.replace("-", "").toUpperCase();
if (ds.config.bridgeid.length() > 12) {
ds.config.bridgeid = ds.config.bridgeid.substring(0, 12);
}
if (config.permanentV1bridge) {
ds.config.makeV1bridge();
}
setLinkbutton(config.pairingEnabled, config.createNewUserOnEveryEndpoint, config.temporarilyEmulateV1bridge);
ds.config.mac = NetworkUtils.getMAC(configuredAddress);
ds.config.ipaddress = getConfiguredHostAddress(configuredAddress);
ds.config.netmask = networkPrefixLength < 32 ? NetUtil.networkPrefixLengthToNetmask(networkPrefixLength)
: "255.255.255.0";
if (eventAdmin != null) {
eventAdmin.postEvent(new Event(EVENT_ADDRESS_CHANGED, Collections.emptyMap()));
}
}
private String getConfiguredHostAddress(InetAddress configuredAddress) {
String hostAddress = configuredAddress.getHostAddress();
int percentIndex = hostAddress.indexOf("%");
if (percentIndex != -1) {
return hostAddress.substring(0, percentIndex);
} else {
return hostAddress;
}
}
@Deactivate
public void deactive(int reason) {
ScheduledFuture<?> future = pairingOffFuture;
if (future != null) {
future.cancel(false);
}
future = writeUUIDFuture;
if (future != null) {
future.cancel(false);
}
}
protected void determineHighestAssignedHueID() {
for (Metadata metadata : metadataRegistry.getAll()) {
if (!metadata.getUID().getNamespace().equals(METAKEY)) {
continue;
}
try {
int hueId = Integer.parseInt(metadata.getValue());
if (hueId > highestAssignedHueID) {
highestAssignedHueID = hueId;
}
} catch (NumberFormatException e) {
logger.warn("A non numeric hue ID '{}' was assigned. Ignoring!", metadata.getValue());
}
}
}
/**
* Although hue IDs are strings, a lot of implementations out there assume them to be numbers. Therefore
* we map each item to a number and store that in the meta data provider.
*
* @param item The item to map
* @return A stringified integer number
*/
public String mapItemUIDtoHueID(Item item) {
MetadataKey key = new MetadataKey(METAKEY, item.getUID());
Metadata metadata = metadataRegistry.get(key);
int hueId = 0;
if (metadata != null) {
try {
hueId = Integer.parseInt(metadata.getValue());
} catch (NumberFormatException e) {
logger.warn("A non numeric hue ID '{}' was assigned. Ignore and reassign a different id now!",
metadata.getValue());
}
}
if (hueId == 0) {
++highestAssignedHueID;
hueId = highestAssignedHueID;
metadataRegistry.add(new Metadata(key, String.valueOf(hueId), null));
}
return String.valueOf(hueId);
}
public boolean isReady() {
return !discoveryIps.isEmpty();
}
public HueEmulationConfig getConfig() {
return config;
}
public int getHighestAssignedHueID() {
return highestAssignedHueID;
}
/**
* Sets the link button state.
*
* Starts a pairing timeout thread if set to true.
* Stops any already running timers.
*
* @param linkbutton New link button state
*/
public void setLinkbutton(boolean linkbutton, boolean createUsersOnEveryEndpoint,
boolean temporarilyEmulateV1bridge) {
ds.config.linkbutton = linkbutton;
config.createNewUserOnEveryEndpoint = createUsersOnEveryEndpoint;
if (temporarilyEmulateV1bridge) {
ds.config.makeV1bridge();
} else if (!config.permanentV1bridge) {
ds.config.makeV2bridge();
}
ScheduledFuture<?> future = pairingOffFuture;
if (future != null) {
future.cancel(false);
}
if (!linkbutton) {
logger.info("Hue Emulation pairing disabled");
return;
}
logger.info("Hue Emulation pairing enabled for {}s", ds.config.networkopenduration);
pairingOffFuture = scheduler.schedule(() -> {
logger.info("Hue Emulation disable pairing...");
if (!config.permanentV1bridge) { // Restore bridge version
ds.config.makeV2bridge();
}
config.createNewUserOnEveryEndpoint = false;
config.temporarilyEmulateV1bridge = false;
WriteConfig.unsetPairingMode(configAdmin);
}, ds.config.networkopenduration * 1000, TimeUnit.MILLISECONDS);
}
public Set<InetAddress> getDiscoveryIps() {
return discoveryIps;
}
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2020 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;
/**
* The pure item type is not enough to decide how we expose an item.
* We need to consider the assigned tags and category as well. This
* computed device type is stored next to the item itself.
*
* @author David Graeff - Initial contribution
*/
public enum DeviceType {
SwitchType,
WhiteType,
WhiteTemperatureType,
ColorType
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2010-2020 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;
import org.eclipse.jdt.annotation.Nullable;
/**
* The configuration for {@link HueEmulationService}.
*
* @author David Graeff - Initial Contribution
*/
@NonNullByDefault
public class HueEmulationConfig {
public boolean pairingEnabled = false;
public static final String CONFIG_PAIRING_ENABLED = "pairingEnabled";
/**
* The Amazon echos have no means to recreate a new api key and they don't care about the 403-forbidden http status
* code. If the addon has pruned its api-key list, echos will not be able to discover new devices. Set this option
* to just create a new user on the fly.
*/
public boolean createNewUserOnEveryEndpoint = false;
public static final String CONFIG_CREATE_NEW_USER_ON_THE_FLY = "createNewUserOnEveryEndpoint";
public boolean temporarilyEmulateV1bridge = false;
public static final String CONFIG_EMULATE_V1 = "temporarilyEmulateV1bridge";
public boolean permanentV1bridge = false;
/** Pairing timeout in seconds */
public int pairingTimeout = 60;
/**
* The field discoveryIps was named discoveryIp in the frontend for some time and thus user probably
* have it in their local config saved under the non plural version.
*/
public @Nullable String discoveryIp;
public int discoveryHttpPort = 0;
/** Comma separated list of tags */
public String restrictToTagsSwitches = "Switchable";
/** Comma separated list of tags */
public String restrictToTagsColorLights = "ColorLighting";
/** Comma separated list of tags */
public String restrictToTagsWhiteLights = "Lighting";
/** Comma separated list of tags */
public String ignoreItemsWithTags = "internal";
public static final String CONFIG_UUID = "uuid";
public String uuid = "";
public String devicename = "openHAB";
}

View File

@@ -0,0 +1,223 @@
/**
* Copyright (c) 2010-2020 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.Dictionary;
import java.util.Hashtable;
import javax.servlet.ServletException;
import javax.ws.rs.ApplicationPath;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.container.PreMatching;
import javax.ws.rs.core.Application;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.http.HttpHeader;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.server.ServerProperties;
import org.glassfish.jersey.servlet.ServletContainer;
import org.glassfish.jersey.servlet.ServletProperties;
import org.openhab.io.hueemulation.internal.rest.ConfigurationAccess;
import org.openhab.io.hueemulation.internal.rest.LightsAndGroups;
import org.openhab.io.hueemulation.internal.rest.Rules;
import org.openhab.io.hueemulation.internal.rest.Scenes;
import org.openhab.io.hueemulation.internal.rest.Schedules;
import org.openhab.io.hueemulation.internal.rest.Sensors;
import org.openhab.io.hueemulation.internal.rest.StatusResource;
import org.openhab.io.hueemulation.internal.rest.UserManagement;
import org.openhab.io.hueemulation.internal.upnp.UpnpServer;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicyOption;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;
import org.osgi.service.http.HttpService;
import org.osgi.service.http.NamespaceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Provides a Hue compatible HTTP REST API on /api.
* <p>
* References all different rest endpoints implemented as JAX-RS annotated classes in the sub-package "rest".
* Those are very modular and have (almost) no inter-dependencies. The UPnP related part is encapsulated in
* the also referenced {@link UpnpServer}.
* <p>
* openHAB items via the {@link org.openhab.core.items.ItemRegistry} are for example mapped to
* /api/{username}/lights and /api/{username}/groups in {@link LightsAndGroups}.
* <p>
* The user management is realized in the {@link UserManagement} component, that is referenced by almost all
* other components and is the only inter-component dependency.
*
* @author David Graeff - Initial Contribution
*/
@NonNullByDefault
@Component(immediate = true, service = { HueEmulationService.class }, property = {
"com.eclipsesource.jaxrs.publish=false" })
public class HueEmulationService implements EventHandler {
public static final String CONFIG_PID = "org.openhab.hueemulation";
public static final String RESTAPI_PATH = "/api";
@ApplicationPath(RESTAPI_PATH)
public static class JerseyApplication extends Application {
}
@PreMatching
public class RequestInterceptor implements ContainerRequestFilter {
@NonNullByDefault({})
@Override
public void filter(ContainerRequestContext requestContext) {
/**
* Jetty returns 415 on any GET request if a client sends the Content-Type header.
* This is a workaround - stripping it away in the preMatching stage.
*/
if (requestContext.getMethod() == HttpMethod.GET
&& requestContext.getHeaders().containsKey(HttpHeader.CONTENT_TYPE.asString())) {
requestContext.getHeaders().remove(HttpHeader.CONTENT_TYPE.asString());
}
}
}
public class LogAccessInterceptor implements ContainerResponseFilter {
@NonNullByDefault({})
@Override
public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
if (!logger.isDebugEnabled()) {
return;
}
logger.debug("REST request {} {}", requestContext.getMethod(), requestContext.getUriInfo().getPath());
logger.debug("REST response: {}", responseContext.getEntity());
}
}
private final ContainerRequestFilter requestCleaner = new RequestInterceptor();
private final Logger logger = LoggerFactory.getLogger(HueEmulationService.class);
private final LogAccessInterceptor accessInterceptor = new LogAccessInterceptor();
//// Required services ////
// Don't fail the service if the upnp server does not come up
// That part is required for discovery only but does not affect already configured hue applications
@Reference(cardinality = ReferenceCardinality.OPTIONAL, policyOption = ReferencePolicyOption.GREEDY)
protected @Nullable UpnpServer discovery;
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) ConfigurationAccess configurationAccess;
@Reference
protected @NonNullByDefault({}) LightsAndGroups lightItems;
@Reference
protected @NonNullByDefault({}) Sensors sensors;
@Reference
protected @NonNullByDefault({}) Scenes scenes;
@Reference
protected @NonNullByDefault({}) Schedules schedules;
@Reference
protected @NonNullByDefault({}) Rules rules;
@Reference
protected @NonNullByDefault({}) StatusResource statusResource;
@Reference
protected @NonNullByDefault({}) HttpService httpService;
private @NonNullByDefault({}) ServiceRegistration<?> eventHandler;
@Activate
protected void activate(BundleContext bc) {
Dictionary<String, Object> properties = new Hashtable<>();
properties.put(EventConstants.EVENT_TOPIC, ConfigStore.EVENT_ADDRESS_CHANGED);
eventHandler = bc.registerService(EventHandler.class, this, properties);
if (cs.isReady()) {
handleEvent(null);
}
}
// Don't restart the service on config change
@Modified
protected void modified() {
}
@Deactivate
protected void deactivate() {
try {
if (eventHandler != null) {
eventHandler.unregister();
}
} catch (IllegalStateException ignore) {
}
try {
httpService.unregister(RESTAPI_PATH);
} catch (IllegalArgumentException ignore) {
}
}
/**
* We have a hard dependency on the {@link ConfigStore} and that it has initialized the Hue DataStore config
* completely. That initialization happens asynchronously and therefore we cannot rely on OSGi activate/modified
* state changes. Instead the {@link EventAdmin} is used and we listen for the
* {@link ConfigStore#EVENT_ADDRESS_CHANGED} event that is fired as soon as the config is ready.
*/
@Override
public void handleEvent(@Nullable Event event) {
try { // Only receive this event once
eventHandler.unregister();
eventHandler = null;
} catch (IllegalStateException ignore) {
}
ResourceConfig resourceConfig = ResourceConfig.forApplicationClass(JerseyApplication.class);
resourceConfig.property(ServerProperties.APPLICATION_NAME, "HueEmulation");
// don't look for implementations described by META-INF/services/*
resourceConfig.property(ServerProperties.METAINF_SERVICES_LOOKUP_DISABLE, true);
// disable auto discovery on server, as it's handled via OSGI
resourceConfig.property(ServerProperties.FEATURE_AUTO_DISCOVERY_DISABLE, true);
resourceConfig.property(ServerProperties.PROCESSING_RESPONSE_ERRORS_ENABLED, true);
resourceConfig.registerInstances(userManagement, configurationAccess, lightItems, sensors, scenes, schedules,
rules, statusResource, accessInterceptor, requestCleaner);
try {
Hashtable<String, String> initParams = new Hashtable<>();
initParams.put("com.sun.jersey.api.json.POJOMappingFeature", "false");
initParams.put(ServletProperties.PROVIDER_WEB_APP, "false");
httpService.registerServlet(RESTAPI_PATH, new ServletContainer(resourceConfig), initParams, null);
UpnpServer localDiscovery = discovery;
if (localDiscovery == null) {
logger.warn("The UPnP Server service has not been started!");
} else if (!localDiscovery.upnpAnnouncementThreadRunning()) {
localDiscovery.handleEvent(null);
}
statusResource.startUpnpSelfTest();
logger.info("Hue Emulation service available under {}", RESTAPI_PATH);
} catch (ServletException | NamespaceException e) {
logger.warn("Could not start Hue Emulation service: {}", e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) 2010-2020 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.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse.HueErrorMessage;
import org.openhab.io.hueemulation.internal.dto.response.HueResponseSuccessSimple;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Network utility methods
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class NetworkUtils {
/**
* Try to get the ethernet interface MAC for the network interface that belongs to the given IP address.
* Returns a default MAC on any failure.
*
* @param address IP address
* @return A MAC of the form "00:00:88:00:bb:ee"
*/
static String getMAC(InetAddress address) {
NetworkInterface networkInterface;
final byte[] mac;
try {
networkInterface = NetworkInterface.getByInetAddress(address);
if (networkInterface == null) {
return "00:00:88:00:bb:ee";
}
mac = networkInterface.getHardwareAddress();
if (mac == null) {
return "00:00:88:00:bb:ee";
}
} catch (SocketException e) {
return "00:00:88:00:bb:ee";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < mac.length; i++) {
sb.append(String.format("%02X%s", mac[i], (i < mac.length - 1) ? ":" : ""));
}
return sb.toString();
}
/**
* Adds cors headers to the given response and returns it.
*/
public static ResponseBuilder ResponseWithCors(ResponseBuilder response) {
return response.encoding(StandardCharsets.UTF_8.name()) //
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Headers", "origin, content-type, accept, authorization")
.header("Access-Control-Allow-Credentials", "true")
.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD")
.header("Access-Control-Max-Age", "1209600");
}
/**
* Creates a json response with the correct Hue error code
*
* @param gson A gson instance
* @param uri The original uri of the request
* @param type Any of HueResponse.*
* @param message A message
* @return
*/
public static Response singleError(Gson gson, UriInfo uri, int type, String message) {
HueResponse e = new HueResponse(new HueErrorMessage(type, uri.getPath().replace("/api", ""), message));
String str = gson.toJson(Collections.singleton(e), new TypeToken<List<?>>() {
}.getType());
int httpCode = 500;
switch (type) {
case HueResponse.UNAUTHORIZED:
httpCode = 403;
break;
case HueResponse.METHOD_NOT_ALLOWED:
httpCode = 405;
break;
case HueResponse.NOT_AVAILABLE:
httpCode = 404;
break;
case HueResponse.ARGUMENTS_INVALID:
case HueResponse.LINK_BUTTON_NOT_PRESSED:
httpCode = 200;
break;
}
return Response.status(httpCode).entity(str).build();
}
public static Response singleSuccess(Gson gson, String message, String uriPart) {
List<HueResponse> responses = new ArrayList<>();
responses.add(new HueResponse(new HueSuccessGeneric(message, uriPart)));
return Response.ok(gson.toJson(responses, new TypeToken<List<?>>() {
}.getType())).build();
}
public static Response singleSuccess(Gson gson, String message) {
List<HueResponseSuccessSimple> responses = new ArrayList<>();
responses.add(new HueResponseSuccessSimple(message));
return Response.ok(gson.toJson(responses, new TypeToken<List<?>>() {
}.getType())).build();
}
public static Response successList(Gson gson, List<HueSuccessGeneric> successList) {
List<HueResponse> responses = new ArrayList<>();
for (HueSuccessGeneric s : successList) {
if (s.isValid()) {
responses.add(new HueResponse(s));
}
}
return Response.ok(gson.toJson(responses, new TypeToken<List<?>>() {
}.getType())).build();
}
}

View File

@@ -0,0 +1,385 @@
/**
* Copyright (c) 2010-2020 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.List;
import java.util.Optional;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.util.ModuleBuilder;
import org.openhab.io.hueemulation.internal.dto.HueDataStore;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Rule utility methods. The Hue scheduler and Hue rules support is based on the automation engine.
* This class provides methods to convert between Hue entries and automation rules.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class RuleUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(RuleUtils.class);
public static Random random = new Random(); // public for test mock
/**
* Splits the given base time (pattern "hh:mm[:ss]") on the colons and return the resulting array.
* If an upper time is given (same pattern), a random number between base and upper time is chosen.
*
* @param baseTime A base time with the pattern hh:mm[:ss]
* @param upperTime An optional upper time or null or an empty string
* @return Time components (hour, minute, seconds).
*/
public static String[] computeRandomizedDayTime(String baseTime, @Nullable String upperTime) {
if (!baseTime.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
throw new IllegalStateException("Time pattern incorrect. Must be 'hh:mm[:ss]'. " + baseTime);
}
String randomizedTime[] = baseTime.split(":");
if (upperTime != null && !upperTime.isEmpty()) {
String[] upperTimeParts = upperTime.split(":");
if (!upperTime.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")
|| randomizedTime.length != upperTimeParts.length) {
throw new IllegalStateException("Random Time pattern incorrect. Must be 'hh:mm[:ss]'. " + upperTime);
}
for (int i = 0; i < randomizedTime.length; ++i) {
int n = Integer.parseInt(randomizedTime[i]);
int n2 = Integer.parseInt(upperTimeParts[i]);
int diff = Math.abs(n2 - n); // Example: 12 and 14 -> diff = 2
if (diff > 0) { // diff = rnd [0,3)
diff = random.nextInt(diff + 1);
}
randomizedTime[i] = String.valueOf(n2 > n ? n + diff : n2 + diff);
}
}
return randomizedTime;
}
/**
* Validates a hue http address used in schedules and hue rules.
*
* @param ds A hue datastore to verify that referred lights/groups do exist
* @param address Relative hue API address. Example: "/api/<username>/groups/1/action" or
* "/api/<username>/lights/1/state"
* @throws IllegalStateException Thrown if address is invalid
*/
@SuppressWarnings({ "unused", "null" })
public static void validateHueHttpAddress(HueDataStore ds, String address) throws IllegalStateException {
String[] validation = address.split("/");
if (validation.length < 6 || !validation[0].isEmpty() || !validation[1].equals("api")) {
throw new IllegalStateException("Given address invalid!");
}
if ("groups".equals(validation[3]) && "action".equals(validation[5])) {
HueGroupEntry entry = ds.groups.get(validation[4]);
if (entry == null) {
throw new IllegalStateException("Group does not exist: " + validation[4]);
}
} else if ("lights".equals(validation[3]) && "state".equals(validation[5])) {
HueLightEntry entry = ds.lights.get(validation[4]);
if (entry == null) {
throw new IllegalStateException("Light does not exist: " + validation[4]);
}
} else {
throw new IllegalStateException("Can only handle groups and lights");
}
}
public static class ConfigHttpAction {
public String url = "";
public String method = "";
public String body = "";
}
@SuppressWarnings({ "unused", "null" })
public static @Nullable HueCommand httpActionToHueCommand(HueDataStore ds, Action a, @Nullable String ruleName) {
ConfigHttpAction config = a.getConfiguration().as(ConfigHttpAction.class);
// Example: "/api/<username>/groups/1/action" or "/api/<username>/lights/1/state"
String[] validation = config.url.split("/");
if (validation.length < 6 || !validation[0].isEmpty() || !"api".equals(validation[1])) {
LOGGER.warn("Hue Rule '{}': Given address in action {} invalid!", ruleName, a.getLabel());
return null;
}
if ("groups".equals(validation[3]) && "action".equals(validation[5])) {
HueGroupEntry gentry = ds.groups.get(validation[4]);
if (gentry == null) {
LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Group does not exist: {}", ruleName,
a.getLabel(), validation[4]);
return null;
}
return new HueCommand(config.url, config.method, config.body);
} else if ("lights".equals(validation[3]) && "state".equals(validation[5])) {
HueLightEntry lentry = ds.lights.get(validation[4]);
if (lentry == null) {
LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Light does not exist: {}", ruleName,
a.getLabel(), validation[4]);
return null;
}
return new HueCommand(config.url, config.method, config.body);
} else {
LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Can only handle lights and groups, not {}",
ruleName, a.getLabel(), validation[3]);
return null;
}
}
public static Action createHttpAction(HueCommand command, String id) {
final Configuration actionConfig = new Configuration();
actionConfig.put("method", command.method);
actionConfig.put("url", command.address);
actionConfig.put("body", command.body);
return ModuleBuilder.createAction().withId(id).withTypeUID("rules.HttpAction").withConfiguration(actionConfig)
.build();
}
// Recurring pattern "W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
private static Trigger createRecurringFromTimeString(String localtime) {
Pattern timePattern = Pattern.compile("W(.*)/T(.*)A(.*)");
Matcher m = timePattern.matcher(localtime);
if (!m.matches() || m.groupCount() < 3) {
throw new IllegalStateException("Recurring time pattern incorrect");
}
String weekdays = m.group(1);
String time = m.group(2);
String randomize = m.group(3);
// Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday = 1
int weekdaysBinaryEncoded = Integer.valueOf(weekdays);
// For the last part of the cron expression ("day"):
// A comma separated list of days starting with 0=sunday to 7=sunday
List<String> cronWeekdays = new ArrayList<>();
for (int bin = 64, c = 1; bin > 0; bin /= 2, c += 1) {
if (weekdaysBinaryEncoded / bin == 1) {
weekdaysBinaryEncoded = weekdaysBinaryEncoded % bin;
cronWeekdays.add(String.valueOf(c));
}
}
String hourMinSec[] = RuleUtils.computeRandomizedDayTime(time, randomize);
// Cron expression: min hour day month weekdays
String cronExpression = hourMinSec[1] + " " + hourMinSec[0] + " * * " + String.join(",", cronWeekdays);
final Configuration triggerConfig = new Configuration();
triggerConfig.put("cronExpression", cronExpression);
return ModuleBuilder.createTrigger().withId("crontrigger").withTypeUID("timer.GenericCronTrigger")
.withConfiguration(triggerConfig).build();
}
// Timer pattern: R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss]
private static Trigger createTimerFromTimeString(String localtime) {
Pattern timePattern = Pattern.compile("R(.*)/PT(.*)A(.*)");
Matcher m = timePattern.matcher(localtime);
if (!m.matches() || m.groupCount() < 3) {
throw new IllegalStateException("Timer pattern incorrect");
}
String run = m.group(1);
String time = m.group(2);
String randomize = m.group(3);
final Configuration triggerConfig = new Configuration();
if (!time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
throw new IllegalStateException("Time pattern incorrect. Must be 'hh:mm[:ss]'. " + time);
}
triggerConfig.put("time", time);
if (randomize.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
triggerConfig.put("randomizeTime", randomize);
}
if (!run.isEmpty()) {
if (!run.matches("[0-9]{1,2}")) {
throw new IllegalStateException("Run pattern incorrent. Must be a number'. " + run);
} else {
triggerConfig.put("repeat", run);
}
} else { // Infinite
triggerConfig.put("repeat", "-1");
}
return ModuleBuilder.createTrigger().withId("timertrigger").withTypeUID("timer.TimerTrigger")
.withConfiguration(triggerConfig).build();
}
// Absolute date/time pattern "[YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
private static Trigger createAbsoluteDateTimeFromTimeString(String localtime) {
Pattern timePattern = Pattern.compile("(.*)T(.*)A(.*)");
Matcher m = timePattern.matcher(localtime);
if (!m.matches() || m.groupCount() < 3) {
throw new IllegalStateException("Absolute date/time pattern incorrect");
}
String date = m.group(1);
String time = m.group(2);
if (!date.matches("[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}")) {
throw new IllegalStateException("Date pattern incorrect. Must be 'yyyy-mm-dd'. " + date);
}
if (!time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
throw new IllegalStateException("Time pattern incorrect. Must be 'hh:mm[:ss]'. " + time);
}
final Configuration triggerConfig = new Configuration();
triggerConfig.put("date", date);
triggerConfig.put("time", time);
time = m.group(3);
if (time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
triggerConfig.put("randomizeTime", time);
}
return ModuleBuilder.createTrigger().withId("absolutetrigger").withTypeUID("timer.AbsoluteDateTimeTrigger")
.withConfiguration(triggerConfig).build();
}
/**
* Creates a trigger based on the given time string.
* According to <a href="https://developers.meethue.com/develop/hue-api/datatypes-and-time-patterns/">the Hue
* documentation</a> this can be:
* <p>
* <ul>
* <li>Absolute time [YYYY]-[MM]-[DD]T[hh]:[mm]:[ss] ([date]T[time])
* <li>Randomized time [YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss] ([date]T[time]A[time])
* <li>Recurring times W[bbb]/T[hh]:[mm]:[ss]
* <li>Every day of the week given by bbb at given time
* <li>Recurring randomized times W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]
* <li>Every weekday given by bbb at given left side time, randomized by right side time. Right side time has to be
* smaller than 12 hours
* <li>
* <ul>
* Timers
* <li>PT[hh]:[mm]:[ss] Timer, expiring after given time
* <li>PT[hh]:[mm]:[ss] Timer, expiring after given time
* <li>PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] Timer with random element
* <li>R[nn]/PT[hh]:[mm]:[ss] Recurring timer
* <li>R/PT[hh]:[mm]:[ss] Recurring timer
* <li>R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] Recurring timer with random element
* </ul>
* </ul>
*
* @param localtimeParameter An absolute time or recurring time or timer pattern
* @return A trigger based on {@link org.openhab.io.hueemulation.internal.automation.AbsoluteDateTimeTriggerHandler}
* or {@link org.openhab.io.hueemulation.internal.automation.TimerTriggerHandler}
*/
public static Trigger createTriggerForTimeString(final String localtimeParameter) throws IllegalStateException {
String localtime = localtimeParameter;
Trigger ruleTrigger;
// Normalize timer patterns
if (localtime.startsWith("PT")) {
localtime = "R1/" + localtime;
}
if (!localtime.contains("A")) {
localtime += "A";
}
// Recurring pattern "W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
if (localtime.startsWith("W")) {
ruleTrigger = createRecurringFromTimeString(localtime);
} // Timer pattern: R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss]
else if (localtime.startsWith("R")) {
ruleTrigger = createTimerFromTimeString(localtime);
} // Absolute date/time pattern "[YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
else {
ruleTrigger = createAbsoluteDateTimeFromTimeString(localtime);
}
return ruleTrigger;
}
public static class TimerConfig {
public String time = "";
public String randomizeTime = "";
public @Nullable Integer repeat;
}
public static @Nullable String timeStringFromTrigger(List<Trigger> triggers) {
Optional<Trigger> trigger;
trigger = triggers.stream().filter(p -> p.getId().equals("crontrigger")).findFirst();
if (trigger.isPresent()) {
String cronParts[] = ((String) trigger.get().getConfiguration().get("cronExpression")).split(" ");
if (cronParts.length != 5) {
LOGGER.warn("Cron trigger has no valid cron expression: {}", String.join(",", cronParts));
return null;
}
// Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday = 1
int weekdays = 0;
String[] cronWeekdays = cronParts[4].split(",");
for (String wdayT : cronWeekdays) {
int wday = Integer.parseInt(wdayT);
switch (wday) {
case 0:
case 7:
weekdays += 1;
break;
case 1:
weekdays += 64;
break;
case 2:
weekdays += 32;
break;
case 3:
weekdays += 16;
break;
case 4:
weekdays += 8;
break;
case 5:
weekdays += 4;
break;
case 6:
weekdays += 2;
break;
}
}
return String.format("W%d/T%s:%s:00", weekdays, cronParts[1], cronParts[0]);
}
trigger = triggers.stream().filter(p -> p.getId().equals("timertrigger")).findFirst();
if (trigger.isPresent()) {
TimerConfig c = trigger.get().getConfiguration().as(TimerConfig.class);
if (c.repeat == null) {
return String.format(c.randomizeTime.isEmpty() ? "PT%s" : "PT%sA%s", c.time, c.randomizeTime);
} else if (c.repeat == -1) {
return String.format(c.randomizeTime.isEmpty() ? "R/PT%s" : "R/PT%sA%s", c.time, c.randomizeTime);
} else {
return String.format(c.randomizeTime.isEmpty() ? "R%d/PT%s" : "R%d/PT%sA%s", c.repeat, c.time,
c.randomizeTime);
}
} else {
trigger = triggers.stream().filter(p -> p.getId().equals("absolutetrigger")).findFirst();
if (trigger.isPresent()) {
String date = (String) trigger.get().getConfiguration().get("date");
String time = (String) trigger.get().getConfiguration().get("time");
String randomizeTime = (String) trigger.get().getConfiguration().get("randomizeTime");
return String.format(randomizeTime == null ? "%sT%s" : "%sT%sA%s", date, time, randomizeTime);
} else {
LOGGER.warn("No recognised trigger type");
return null;
}
}
}
}

View File

@@ -0,0 +1,463 @@
/**
* Copyright (c) 2010-2020 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.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.io.hueemulation.internal.dto.AbstractHueState;
import org.openhab.io.hueemulation.internal.dto.HueStateBulb;
import org.openhab.io.hueemulation.internal.dto.HueStateColorBulb;
import org.openhab.io.hueemulation.internal.dto.HueStateColorBulb.ColorMode;
import org.openhab.io.hueemulation.internal.dto.HueStatePlug;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse.HueErrorMessage;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseStateChanged;
/**
* This utility class provides all kind of functions to convert between openHAB item states to Hue states and back
* as well as applying a hue state change request to a hue state or openHAB item state.
* <p>
* It also provides methods to determine the hue type (plug, white bulb, coloured bulb), given an item.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class StateUtils {
/**
* Compute the hue state from a given item state and a device type.
*
* @param itemState The item state
* @param deviceType The device type
* @return A hue light state
*/
public static AbstractHueState colorStateFromItemState(State itemState, @Nullable DeviceType deviceType) {
if (deviceType == null) {
return new HueStatePlug(false);
}
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);
} else {
state = new HueStateColorBulb(new HSBType());
}
break;
case WhiteType:
case WhiteTemperatureType:
if (itemState instanceof HSBType) {
PercentType brightness = ((HSBType) itemState).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 {
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);
} else {
state = new HueStatePlug(false);
}
}
return state;
}
/**
* Computes an openHAB item state, given a hue state.
*
* <p>
* This only proxies to the respective call
* on the concrete hue state implementation.
*
* @throws IllegalStateException Thrown if the concrete hue state is not yet handled by this method.
*/
public static State itemStateByHueState(AbstractHueState state) throws IllegalStateException {
if (state instanceof HueStateColorBulb) {
return state.as(HueStateColorBulb.class).toHSBType();
} else if (state instanceof HueStateBulb) {
return state.as(HueStateBulb.class).toBrightnessType();
} else if (state instanceof HueStatePlug) {
return state.as(HueStatePlug.class).toOnOffType();
} else {
throw new IllegalStateException();
}
}
/**
* An openHAB state is usually also a command. Cast the state.
*
* @throws IllegalStateException Throws if the cast fails.
*/
public static Command commandByItemState(State state) throws IllegalStateException {
if (state instanceof Command) {
return (Command) state;
} else {
throw new IllegalStateException();
}
}
/**
* Computes an openHAB command, given a hue state change request.
*
* @param changeRequest The change request
*/
public static @Nullable Command computeCommandByChangeRequest(HueStateChange changeRequest) {
List<HueResponse> responses = new ArrayList<>();
return computeCommandByState(responses, "", new HueStateColorBulb(false), changeRequest);
}
/**
* Apply the new received state from the REST PUT request.
*
* @param responses Creates a response entry for each success and each error. There is one entry per non-null field
* of {@link HueStateChange} created.
* @param prefix The response entry prefix, for example "/groups/mygroupid/state/"
* @param state The current item state
* @param newState A state change DTO
* @return Return a command computed via the incoming state object.
*/
public static @Nullable Command computeCommandByState(List<HueResponse> responses, String prefix,
AbstractHueState state, HueStateChange newState) {
// Apply new state and collect success, error items
Map<String, Object> successApplied = new TreeMap<>();
List<String> errorApplied = new ArrayList<>();
Command command = null;
if (newState.on != null) {
try {
state.as(HueStatePlug.class).on = newState.on;
command = OnOffType.from(newState.on);
successApplied.put("on", newState.on);
} catch (ClassCastException e) {
errorApplied.add("on");
}
}
if (newState.bri != null) {
try {
state.as(HueStateBulb.class).bri = newState.bri;
command = new PercentType((int) (newState.bri * 100.0 / HueStateBulb.MAX_BRI + 0.5));
successApplied.put("bri", newState.bri);
} catch (ClassCastException e) {
errorApplied.add("bri");
}
}
if (newState.bri_inc != null) {
try {
int newBri = state.as(HueStateBulb.class).bri + newState.bri_inc;
if (newBri < 0 || newBri > HueStateBulb.MAX_BRI) {
throw new IllegalArgumentException();
}
command = new PercentType((int) (newBri * 100.0 / HueStateBulb.MAX_BRI + 0.5));
successApplied.put("bri", newState.bri);
} catch (ClassCastException e) {
errorApplied.add("bri_inc");
} catch (IllegalArgumentException e) {
errorApplied.add("bri_inc");
}
}
if (newState.sat != null) {
try {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
c.sat = newState.sat;
c.colormode = ColorMode.hs;
command = c.toHSBType();
successApplied.put("sat", newState.sat);
} catch (ClassCastException e) {
errorApplied.add("sat");
}
}
if (newState.sat_inc != null) {
try {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
int newV = c.sat + newState.sat_inc;
if (newV < 0 || newV > HueStateColorBulb.MAX_SAT) {
throw new IllegalArgumentException();
}
c.colormode = ColorMode.hs;
c.sat = newV;
command = c.toHSBType();
successApplied.put("sat", newState.sat);
} catch (ClassCastException e) {
errorApplied.add("sat_inc");
} catch (IllegalArgumentException e) {
errorApplied.add("sat_inc");
}
}
if (newState.hue != null) {
try {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
c.colormode = ColorMode.hs;
c.hue = newState.hue;
command = c.toHSBType();
successApplied.put("hue", newState.hue);
} catch (ClassCastException e) {
errorApplied.add("hue");
}
}
if (newState.hue_inc != null) {
try {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
int newV = c.hue + newState.hue_inc;
if (newV < 0 || newV > HueStateColorBulb.MAX_HUE) {
throw new IllegalArgumentException();
}
c.colormode = ColorMode.hs;
c.hue = newV;
command = c.toHSBType();
successApplied.put("hue", newState.hue);
} catch (ClassCastException e) {
errorApplied.add("hue_inc");
} catch (IllegalArgumentException e) {
errorApplied.add("hue_inc");
}
}
if (newState.ct != null) {
try {
// We can't do anything here with a white color temperature.
// The core ESH color type does not support setting it.
// Adjusting the color temperature implies setting the mode to ct
if (state instanceof HueStateColorBulb) {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
c.sat = 0;
c.colormode = ColorMode.ct;
command = c.toHSBType();
}
successApplied.put("colormode", ColorMode.ct);
successApplied.put("sat", 0);
successApplied.put("ct", newState.ct);
} catch (ClassCastException e) {
errorApplied.add("ct");
}
}
if (newState.ct_inc != null) {
try {
// We can't do anything here with a white color temperature.
// The core ESH color type does not support setting it.
// Adjusting the color temperature implies setting the mode to ct
if (state instanceof HueStateColorBulb) {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
if (c.colormode != ColorMode.ct) {
c.sat = 0;
command = c.toHSBType();
successApplied.put("colormode", c.colormode);
}
}
successApplied.put("ct", newState.ct);
} catch (ClassCastException e) {
errorApplied.add("ct_inc");
}
}
if (newState.transitiontime != null) {
successApplied.put("transitiontime", newState.transitiontime); // Pretend that worked
}
if (newState.alert != null) {
successApplied.put("alert", newState.alert); // Pretend that worked
}
if (newState.effect != null) {
successApplied.put("effect", newState.effect); // Pretend that worked
}
if (newState.xy != null) {
try {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
c.colormode = ColorMode.xy;
c.bri = state.as(HueStateBulb.class).bri;
c.xy[0] = newState.xy.get(0);
c.xy[1] = newState.xy.get(1);
command = c.toHSBType();
successApplied.put("xy", newState.xy);
} catch (ClassCastException e) {
errorApplied.add("xy");
}
}
if (newState.xy_inc != null) {
try {
HueStateColorBulb c = state.as(HueStateColorBulb.class);
double newX = c.xy[0] + newState.xy_inc.get(0);
double newY = c.xy[1] + newState.xy_inc.get(1);
if (newX < 0 || newX > 1 || newY < 0 || newY > 1) {
throw new IllegalArgumentException();
}
c.colormode = ColorMode.xy;
c.bri = state.as(HueStateBulb.class).bri;
c.xy[0] = newX;
c.xy[1] = newY;
command = c.toHSBType();
successApplied.put("xy", newState.xy_inc);
} catch (ClassCastException e) {
errorApplied.add("xy_inc");
} catch (IllegalArgumentException e) {
errorApplied.add("xy_inc");
}
}
// Generate the response. The response consists of a list with an entry each for all
// submitted change requests. If for example "on" and "bri" was send, 2 entries in the response are
// expected.
successApplied.forEach((t, v) -> {
responses.add(new HueResponse(new HueSuccessResponseStateChanged(prefix + "/" + t, v)));
});
errorApplied.forEach(v -> {
responses.add(
new HueResponse(new HueErrorMessage(HueResponse.NOT_AVAILABLE, prefix + "/" + v, "Could not set")));
});
return command;
}
public static @Nullable DeviceType determineTargetType(ConfigStore cs, Item element) {
String category = element.getCategory();
String type = element.getType();
Set<String> tags = element.getTags();
// Determine type, heuristically
DeviceType t = null;
// The user wants this item to be not exposed
if (cs.ignoreItemsFilter.stream().anyMatch(tags::contains)) {
return null;
}
// First consider the category
if (category != null) {
switch (category) {
case "ColorLight":
t = DeviceType.ColorType;
break;
case "Light":
t = DeviceType.SwitchType;
}
}
// Then the tags
if (cs.switchFilter.stream().anyMatch(tags::contains)) {
t = DeviceType.SwitchType;
}
if (cs.whiteFilter.stream().anyMatch(tags::contains)) {
t = DeviceType.WhiteTemperatureType;
}
if (cs.colorFilter.stream().anyMatch(tags::contains)) {
t = DeviceType.ColorType;
}
// Last but not least, the item type
if (t == null) {
switch (type) {
case CoreItemFactory.COLOR:
if (cs.colorFilter.isEmpty()) {
t = DeviceType.ColorType;
}
break;
case CoreItemFactory.DIMMER:
case CoreItemFactory.ROLLERSHUTTER:
if (cs.whiteFilter.isEmpty()) {
t = DeviceType.WhiteTemperatureType;
}
break;
case CoreItemFactory.SWITCH:
if (cs.switchFilter.isEmpty()) {
t = DeviceType.SwitchType;
}
break;
}
}
return t;
}
/**
* Compute the hue state from a given item state and a device type.
* If the item state matches the last command. the hue state is adjusted
* to use the values from the last hue state change. This is done to prevent
* Alexa reporting device errors.
*
* @param itemState The item state
* @param deviceType The device type
* @param lastCommand The last command
* @param lastHueChange The last hue state change
* @return A hue light state
*/
public static AbstractHueState adjustedColorStateFromItemState(State itemState, @Nullable DeviceType deviceType,
@Nullable Command lastCommand, @Nullable HueStateChange lastHueChange) {
AbstractHueState hueState = colorStateFromItemState(itemState, deviceType);
if (lastCommand != null && lastHueChange != null) {
if (lastCommand instanceof HSBType) {
if (hueState instanceof HueStateColorBulb && itemState.as(HSBType.class).equals(lastCommand)) {
HueStateColorBulb c = (HueStateColorBulb) hueState;
if (lastHueChange.bri != null) {
c.bri = lastHueChange.bri;
}
if (lastHueChange.hue != null) {
c.hue = lastHueChange.hue;
}
if (lastHueChange.sat != null) {
c.sat = lastHueChange.sat;
}
// Although we can't set a colour temperature in OH
// this keeps Alexa happy when asking to turn a light
// to white.
if (lastHueChange.ct != null) {
c.ct = lastHueChange.ct;
}
}
} else if (lastCommand instanceof PercentType) {
if (hueState instanceof HueStateBulb && itemState.as(PercentType.class).equals(lastCommand)) {
if (lastHueChange.bri != null) {
((HueStateBulb) hueState).bri = lastHueChange.bri;
}
}
}
}
return hueState;
}
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2010-2020 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.io.IOException;
import java.util.Dictionary;
import java.util.Hashtable;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.osgi.service.cm.Configuration;
import org.osgi.service.cm.ConfigurationAdmin;
/**
* Find all configuration admin interactions in this class
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class WriteConfig {
public static void setUUID(ConfigurationAdmin configAdmin, String uuid) {
try {
Configuration configuration = configAdmin.getConfiguration(HueEmulationService.CONFIG_PID, null);
Dictionary<String, Object> dictionary = configuration.getProperties();
if (dictionary == null) {
dictionary = new Hashtable<>();
}
dictionary.put(HueEmulationConfig.CONFIG_UUID, uuid);
configuration.update(dictionary); // This will restart the service (and call activate again)
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
/**
* Persists the link mode off state
*/
public static void unsetPairingMode(ConfigurationAdmin configAdmin) {
try {
org.osgi.service.cm.Configuration configuration = configAdmin
.getConfiguration(HueEmulationService.CONFIG_PID, null);
Dictionary<String, Object> dictionary = configuration.getProperties();
dictionary.put(HueEmulationConfig.CONFIG_PAIRING_ENABLED, false);
dictionary.put(HueEmulationConfig.CONFIG_CREATE_NEW_USER_ON_THE_FLY, false);
dictionary.put(HueEmulationConfig.CONFIG_EMULATE_V1, false);
configuration.update(dictionary);
} catch (IOException ignore) {
}
}
}

View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.scheduler.SchedulerRunnable;
import org.openhab.core.automation.ModuleHandlerCallback;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.handler.BaseTriggerModuleHandler;
import org.openhab.core.automation.handler.TriggerHandlerCallback;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Extends the core provided time related module type by an absolute day/time trigger.
* <p>
* It allows to set a date and a time as separate configuration values (easier to manipulate from
* other actions / rules etc) and also allows the user to setup a random factor
* (presence simulation).
*
* @author David Graeff - Initial contribution
*/
public class AbsoluteDateTimeTriggerHandler extends BaseTriggerModuleHandler implements SchedulerRunnable {
private final Logger logger = LoggerFactory.getLogger(AbsoluteDateTimeTriggerHandler.class);
public static final String MODULE_TYPE_ID = "timer.AbsoluteDateTimeTrigger";
public static final String CALLBACK_CONTEXT_NAME = "CALLBACK";
public static final String MODULE_CONTEXT_NAME = "MODULE";
public static final String CFG_DATE = "date";
public static final String CFG_TIME = "time";
public static final String CFG_TIME_RND = "randomizeTime";
private final Scheduler scheduler;
private final Instant dateTime;
private ScheduledCompletableFuture<?> schedule;
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final String TIME_FORMAT = "HH:mm:ss";
private static final String DATETIME_FORMAT = DATE_FORMAT + " " + TIME_FORMAT;
private final DateTimeFormatter dateTimeformatter;
public AbsoluteDateTimeTriggerHandler(Trigger module, Scheduler scheduler) {
super(module);
this.scheduler = scheduler;
dateTimeformatter = DateTimeFormatter.ofPattern(DATETIME_FORMAT);
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(DATE_FORMAT);
DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern(TIME_FORMAT);
// Take optional date into account
String cfgDate = (String) module.getConfiguration().get(CFG_DATE);
LocalDateTime dateTime = cfgDate == null || cfgDate.isEmpty() ? LocalDate.now().atStartOfDay()
: LocalDateTime.from(dateFormatter.parse(cfgDate));
// Take optional time into account
String cfgDTime = (String) module.getConfiguration().get(CFG_TIME);
if (cfgDTime != null && !cfgDTime.isEmpty()) {
TemporalAccessor temporalAccessor = timeFormatter.parse(cfgDTime);
dateTime.plusHours(temporalAccessor.getLong(ChronoField.HOUR_OF_DAY));
dateTime.plusMinutes(temporalAccessor.getLong(ChronoField.MINUTE_OF_HOUR));
}
this.dateTime = dateTime.atZone(ZoneId.systemDefault()).toInstant();
}
@Override
public synchronized void setCallback(ModuleHandlerCallback callback) {
super.setCallback(callback);
scheduleJob();
}
private void scheduleJob() {
schedule = scheduler.at(this, dateTime);
logger.debug("Scheduled absolute date/time '{}' for trigger '{}'.", dateTimeformatter.format(dateTime),
module.getId());
}
@Override
public synchronized void dispose() {
super.dispose();
if (schedule != null) {
schedule.cancel(true);
logger.debug("cancelled job for trigger '{}'.", module.getId());
}
}
@Override
public void run() {
((TriggerHandlerCallback) callback).triggered(module, null);
schedule = null;
}
}

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.handler.ActionHandler;
import org.openhab.core.automation.handler.BaseModuleHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The action module type handled by this class allows to execute a http request (GET, POST, PUT, etc)
* on a given address. Relative addresses are supported.
* <p>
* The optional mimetype and body configuration parameters allow to send arbitrary data with a request.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HttpActionHandler extends BaseModuleHandler<Action> implements ActionHandler {
private final Logger logger = LoggerFactory.getLogger(HttpActionHandler.class);
public static final String MODULE_TYPE_ID = "rules.HttpAction";
public static final String CALLBACK_CONTEXT_NAME = "CALLBACK";
public static final String MODULE_CONTEXT_NAME = "MODULE";
public static final String CFG_METHOD = "method";
public static final String CFG_URL = "url";
public static final String CFG_BODY = "body";
public static final String CFG_MIMETYPE = "mimetype";
public static final String CFG_TIMEOUT = "timeout";
private static class Config {
HttpMethod method = HttpMethod.GET;
String url = "";
String body = "";
String mimetype = "application/json";
int timeout = 5;
}
private Config config = new Config();
private HttpClient httpClient;
public HttpActionHandler(final Action module, HttpClientFactory httpFactory) {
super(module);
this.config = module.getConfiguration().as(Config.class);
if (config.url.isEmpty()) {
throw new IllegalArgumentException("URL not set!");
}
// convert relative path to absolute one
String url = config.url;
if (url.startsWith("/")) {
config.url = "http://localhost:" + Integer.getInteger("org.osgi.service.http.port", 8080).toString() + url;
}
httpClient = httpFactory.createHttpClient("HttpActionHandler_" + module.getId());
}
@Override
public @Nullable Map<String, Object> execute(Map<String, Object> context) {
try {
Request request = httpClient.newRequest(URI.create(config.url)).method(config.method)
.timeout(config.timeout, TimeUnit.SECONDS);
if (config.method == HttpMethod.POST || config.method == HttpMethod.PUT) {
request.content(new StringContentProvider(config.body), config.mimetype);
}
request.send();
} catch (InterruptedException | TimeoutException | ExecutionException e) {
logger.warn("Failed to send http request", e);
}
return null;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.util.Arrays;
import java.util.Collection;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.automation.Condition;
import org.openhab.core.automation.Module;
import org.openhab.core.automation.handler.BaseModuleHandlerFactory;
import org.openhab.core.automation.handler.ModuleHandler;
import org.openhab.core.automation.handler.ModuleHandlerFactory;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Triggers, Conditions, Actions specific to the Hue emulation service
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = ModuleHandlerFactory.class)
public class HueHandlerFactory extends BaseModuleHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(HueHandlerFactory.class);
private static final Collection<String> TYPES = Arrays
.asList(new String[] { HueRuleConditionHandler.MODULE_TYPE_ID });
@Reference
protected @NonNullByDefault({}) ConfigStore configStore;
@Override
@Deactivate
public void deactivate() {
super.deactivate();
}
@Override
public Collection<String> getTypes() {
return TYPES;
}
@Override
protected @Nullable ModuleHandler internalCreate(Module module, String ruleUID) {
logger.trace("create {} -> {}", module.getId(), module.getTypeUID());
String moduleTypeUID = module.getTypeUID();
if (HueRuleConditionHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Condition) {
return new HueRuleConditionHandler((Condition) module, configStore.ds);
} else {
logger.error("The module handler type '{}' is not supported.", moduleTypeUID);
}
return null;
}
}

View File

@@ -0,0 +1,240 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.types.State;
import org.openhab.core.automation.Condition;
import org.openhab.core.automation.handler.BaseModuleHandler;
import org.openhab.core.automation.handler.ConditionHandler;
import org.openhab.io.hueemulation.internal.dto.HueDataStore;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry.Operator;
import org.openhab.io.hueemulation.internal.dto.HueSensorEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This condition is parameterized with Hue rule condition arguments. A Hue rule works
* on the Hue datastore and considers lights / groups / sensors that are available there.
* <p>
* Implementation details: The predicate function for the condition is computed in the constructor of
* this condition. It will only be called and evaluated for isSatisfied.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueRuleConditionHandler extends BaseModuleHandler<Condition> implements ConditionHandler {
private final Logger logger = LoggerFactory.getLogger(HueRuleConditionHandler.class);
public static final String MODULE_TYPE_ID = "hue.ruleCondition";
public static final String CALLBACK_CONTEXT_NAME = "CALLBACK";
public static final String MODULE_CONTEXT_NAME = "MODULE";
public static final String CFG_ADDRESS = "address";
public static final String CFG_OP = "operator";
public static final String CFG_VALUE = "value";
private static final String TIME_FORMAT = "HH:mm:ss";
protected final HueRuleEntry.Condition config;
protected String itemUID;
protected Predicate<State> predicate;
private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern(TIME_FORMAT);
private final Pattern timePattern = Pattern.compile("W(.*)/T(.*)/T(.*)");
// weekdays range from Monday to Sunday (1-7). The first entry is not used
private final boolean weekDaysAllowed[] = { false, false, false, false, false, false, false, false };
@SuppressWarnings({ "null", "unused" })
public HueRuleConditionHandler(Condition module, HueDataStore ds) {
super(module);
config = module.getConfiguration().as(HueRuleEntry.Condition.class);
// pattern: "/sensors/2/state/buttonevent" or "/config/localtime"
String validation[] = config.address.split("/");
String uid = validation[2];
if ("groups".equals(validation[1]) && "action".equals(validation[3])) {
HueGroupEntry entry = ds.groups.get(uid);
if (entry == null) {
throw new IllegalStateException("Group does not exist: " + uid);
}
itemUID = entry.groupItem.getUID();
} else if ("lights".equals(validation[1]) && "state".equals(validation[3])) {
HueLightEntry entry = ds.lights.get(uid);
if (entry == null) {
throw new IllegalStateException("Light does not exist: " + uid);
}
itemUID = entry.item.getUID();
} else if ("sensors".equals(validation[1]) && "state".equals(validation[3])) {
HueSensorEntry entry = ds.sensors.get(uid);
if (entry == null) {
throw new IllegalStateException("Sensor does not exist: " + uid);
}
itemUID = entry.item.getUID();
} else if ("config".equals(validation[1]) && "localtime".equals(validation[2])) {
// Item not used
itemUID = "";
} else {
throw new IllegalStateException("Can only handle groups and lights");
}
if (itemUID == null) {
throw new IllegalStateException("Can only handle groups and lights");
}
final String value = config.value;
switch (config.operator) {
case eq:
if (value == null) {
throw new IllegalStateException("Equal operator requires a value!");
}
predicate = state -> {
if (state instanceof Number) {
return Integer.valueOf(value) == ((Number) state).intValue();
} else if (state instanceof OnOffType) {
return Boolean.valueOf(value) == (((OnOffType) state) == OnOffType.ON);
} else if (state instanceof OpenClosedType) {
return Boolean.valueOf(value) == (((OpenClosedType) state) == OpenClosedType.OPEN);
}
return state.toFullString().equals(value);
};
break;
case gt:
if (value == null) {
throw new IllegalStateException("GreaterThan operator requires a value!");
} else {
final Integer integer = Integer.valueOf(value);
predicate = state -> {
if (state instanceof Number) {
return integer < ((Number) state).intValue();
} else {
return false;
}
};
}
break;
case lt:
if (value == null) {
throw new IllegalStateException("LowerThan operator requires a value!");
} else {
final Integer integer = Integer.valueOf(value);
predicate = state -> {
if (state instanceof Number) {
return integer > ((Number) state).intValue();
} else {
return false;
}
};
}
break;
case in:
case not_in:
if (value == null) {
throw new IllegalStateException("InRange operator requires a value!");
}
Matcher m = timePattern.matcher(!value.startsWith("W") ? "W127/" + value : value);
if (!m.matches() || m.groupCount() < 3) {
throw new IllegalStateException(
"Time pattern incorrect for in/not_in hue rule condition: " + value);
}
final LocalTime timeStart = LocalTime.from(timeFormatter.parse(m.group(2)));
final LocalTime timeEnd = LocalTime.from(timeFormatter.parse(m.group(3)));
// Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday = 1
int weekdaysBinaryEncoded = Integer.valueOf(m.group(1));
List<String> cronWeekdays = new ArrayList<>();
for (int bin = 64, c = 1; bin > 0; bin /= 2, c += 1) {
if (weekdaysBinaryEncoded / bin == 1) {
weekdaysBinaryEncoded = weekdaysBinaryEncoded % bin;
weekDaysAllowed[c] = true;
}
}
predicate = state -> {
LocalDateTime now = getNow();
int dow = now.get(ChronoField.DAY_OF_WEEK);
LocalTime localTime = now.toLocalTime();
return weekDaysAllowed[dow] && localTime.isAfter(timeStart) && localTime.isBefore(timeEnd)
&& config.operator == Operator.in;
};
break;
default:
predicate = s -> true;
break;
}
}
// For test injection
protected LocalDateTime getNow() {
return LocalDateTime.now();
}
@NonNullByDefault({})
@Override
public boolean isSatisfied(Map<String, Object> context) {
switch (config.operator) {
case in:
case not_in:
return predicate.test(OnOffType.ON);
default:
}
State state = (State) context.get("newState");
State oldState = (State) context.get("oldState");
if (state == null || oldState == null) {
logger.warn("Expected a state and oldState input or an in/not_in operator!");
return false;
}
switch (config.operator) {
case ddx: // Item changes always satisfies the "hue change" and "hue change delay" condition
case dx:
return true;
case eq:
case gt:
case lt:
return predicate.test(state);
case not_stable: // state changed?
return (!state.toFullString().equals(oldState.toFullString()));
case stable: // state stable?
return (state.toFullString().equals(oldState.toFullString()));
case unknown:
default:
logger.warn("Operator {} not handled! ", config.operator.name());
return false;
}
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.handler.ActionHandler;
import org.openhab.core.automation.handler.BaseModuleHandler;
/**
* This action module type allows to remove a rule from the rule registry.
* <p>
* This is very useful for rules that should execute only once etc.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class RemoveRuleActionHandler extends BaseModuleHandler<Action> implements ActionHandler {
public static final String MODULE_TYPE_ID = "rules.RemoveRuleAction";
public static final String CALLBACK_CONTEXT_NAME = "CALLBACK";
public static final String MODULE_CONTEXT_NAME = "MODULE";
public static final String CFG_REMOVE_UID = "removeuid";
private final String ruleUID;
private RuleRegistry ruleRegistry;
@SuppressWarnings({ "null", "unused" })
public RemoveRuleActionHandler(final Action module, RuleRegistry ruleRegistry) {
super(module);
this.ruleRegistry = ruleRegistry;
final Configuration config = module.getConfiguration();
if (config.getProperties().isEmpty()) {
throw new IllegalArgumentException("'Configuration' can not be empty.");
}
ruleUID = (String) config.get(CFG_REMOVE_UID);
if (ruleUID == null) {
throw new IllegalArgumentException("'ruleUIDs' property must not be null.");
}
}
@Override
public @Nullable Map<String, Object> execute(Map<String, Object> context) {
ruleRegistry.remove(ruleUID);
return null;
}
}

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.util.Arrays;
import java.util.Collection;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.Module;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.handler.BaseModuleHandlerFactory;
import org.openhab.core.automation.handler.ModuleHandler;
import org.openhab.core.automation.handler.ModuleHandlerFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This factory is responsible for rule and http related module types.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = ModuleHandlerFactory.class)
public class RulesHandlerFactory extends BaseModuleHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(RulesHandlerFactory.class);
private static final Collection<String> TYPES = Arrays
.asList(new String[] { RemoveRuleActionHandler.MODULE_TYPE_ID, HttpActionHandler.MODULE_TYPE_ID });
@Reference
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
@Reference
protected @NonNullByDefault({}) HttpClientFactory httpFactory;
@Override
@Deactivate
public void deactivate() {
super.deactivate();
}
@Override
public Collection<String> getTypes() {
return TYPES;
}
@Override
protected @Nullable ModuleHandler internalCreate(Module module, String ruleUID) {
logger.trace("create {} -> {}", module.getId(), module.getTypeUID());
String moduleTypeUID = module.getTypeUID();
if (RemoveRuleActionHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Action) {
return new RemoveRuleActionHandler((Action) module, ruleRegistry);
} else if (HttpActionHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Action) {
return new HttpActionHandler((Action) module, httpFactory);
} else {
logger.error("The module handler type '{}' is not supported.", moduleTypeUID);
}
return null;
}
}

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.util.Arrays;
import java.util.Collection;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.automation.Module;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.handler.BaseModuleHandlerFactory;
import org.openhab.core.automation.handler.ModuleHandler;
import org.openhab.core.automation.handler.ModuleHandlerFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This factory is responsible for timer related module types.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
@Component(immediate = true, service = ModuleHandlerFactory.class)
public class TimerModuleExHandlerFactory extends BaseModuleHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(TimerModuleExHandlerFactory.class);
private static final Collection<String> TYPES = Arrays
.asList(new String[] { AbsoluteDateTimeTriggerHandler.MODULE_TYPE_ID, TimerTriggerHandler.MODULE_TYPE_ID });
@Reference
private @NonNullByDefault({}) Scheduler scheduler;
@Override
@Deactivate
public void deactivate() {
super.deactivate();
}
@Override
public Collection<String> getTypes() {
return TYPES;
}
@Override
protected @Nullable ModuleHandler internalCreate(Module module, String ruleUID) {
logger.trace("create {} -> {}", module.getId(), module.getTypeUID());
String moduleTypeUID = module.getTypeUID();
if (AbsoluteDateTimeTriggerHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Trigger) {
return new AbsoluteDateTimeTriggerHandler((Trigger) module, scheduler);
} else if (TimerTriggerHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Trigger) {
return new TimerTriggerHandler((Trigger) module, scheduler);
} else {
logger.error("The module handler type '{}' is not supported.", moduleTypeUID);
}
return null;
}
}

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2010-2020 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.automation;
import java.time.Duration;
import java.util.Random;
import java.util.concurrent.Callable;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.automation.ModuleHandlerCallback;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.handler.BaseTriggerModuleHandler;
import org.openhab.core.automation.handler.TriggerHandlerCallback;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This trigger module time allows a trigger that is setup with a time (hours:minutes:seconds).
* As soon as that time has run up, it will trigger.
* <p>
* A random factor and repeat times can also be configured.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class TimerTriggerHandler extends BaseTriggerModuleHandler implements Callable<Duration> {
private final Logger logger = LoggerFactory.getLogger(TimerTriggerHandler.class);
public static final String MODULE_TYPE_ID = "timer.TimerTrigger";
public static final String CALLBACK_CONTEXT_NAME = "CALLBACK";
public static final String MODULE_CONTEXT_NAME = "MODULE";
public static final String CFG_REPEAT = "repeat";
public static final String CFG_TIME = "time";
public static final String CFG_TIME_RND = "randomizeTime";
private final Scheduler scheduler;
private final Duration duration;
private @Nullable ScheduledCompletableFuture<?> schedule;
private static class Config {
int repeat = 1;
String time = "";
String randomizeTime = "";
}
Config config;
public TimerTriggerHandler(Trigger module, Scheduler scheduler) {
super(module);
this.scheduler = scheduler;
config = module.getConfiguration().as(Config.class);
String[] fields = config.time.split(":");
Duration d1 = Duration.parse(String.format("P%dH%dM%sS", fields[0], fields[1], fields[2]));
// Take optional random time (a range-like parameter) into account
if (!config.randomizeTime.isEmpty()) {
fields = config.randomizeTime.split(":");
Duration d2 = Duration.parse(String.format("P%dH%dM%sS", fields[0], fields[1], fields[2]));
// The random time must be later a bigger value than time
if (d1.compareTo(d2) >= 0) {
throw new IllegalArgumentException();
}
// Compute the difference, turn in to seconds, get a random second value between 0 and that upper bound
// and then add it to the base time
Duration difference = d2.minus(d1);
duration = d1.plus(Duration.ofSeconds(randomSeconds(difference.getSeconds())));
} else {
duration = d1;
}
}
protected long randomSeconds(long maximum) {
return Math.abs(new Random().nextLong()) % maximum;
}
@Override
public synchronized void setCallback(ModuleHandlerCallback callback) {
super.setCallback(callback);
if (config.repeat != 0) {
scheduleJob();
}
}
private void scheduleJob() {
schedule = scheduler.after(this, duration);
logger.debug("Scheduled timer to expire in '{}' for trigger '{}'.", duration, module.getId());
}
@Override
public synchronized void dispose() {
super.dispose();
ScheduledCompletableFuture<?> future = schedule;
if (future != null) {
future.cancel(true);
logger.debug("cancelled job for trigger '{}'.", module.getId());
}
}
@Override
public Duration call() {
((TriggerHandlerCallback) callback).triggered(module, null);
config.repeat -= 1;
if (config.repeat == 0) {
schedule = null;
} else {
scheduleJob();
}
return duration;
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2020 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.automation.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry;
/**
* A configuration holder class for the rule trigger handler
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueRuleTriggerConfig {
public String address = "";
public HueRuleEntry.Operator operator = HueRuleEntry.Operator.dx;
public @Nullable String value;
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2020 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.automation.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* A configuration holder class for the item command handler
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class ItemCommandActionConfig {
public String itemName = "";
public String command = "";
}

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2020 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.dto;
/**
* Hue API state object
*
* @author David Graeff - Initial contribution
*
*/
public class AbstractHueState {
public boolean reachable = true;
public String mode = "homeautomation";
public static enum AlertEnum {
none,
/** flashes light once */
select,
/** flashes repeatedly for 10 seconds. */
lselect
}
public String alert = AlertEnum.none.name();
public <T extends AbstractHueState> T as(Class<T> type) throws ClassCastException {
return type.cast(this);
}
}

View File

@@ -0,0 +1,98 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.lang.reflect.Type;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonElement;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* Hue API config object
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueAuthorizedConfig extends HueUnauthorizedConfig {
public String uuid = ""; // Example: 5673dfa7-272c-4315-9955-252cdd86131c
public String timeformat = "24h";
public String timezone = ZonedDateTime.now().getOffset().getId().replace("Z", "+00:00");
public String UTC = "2018-11-10T15:24:23";
public String localtime = "2018-11-10T16:24:23";
public String devicename = "openHAB";
public String fwversion = "0x262e0500";
public boolean rfconnected = true;
public int zigbeechannel = 15;
public boolean linkbutton = false;
public transient boolean createNewUserOnEveryEndpoint = false;
public int panid = 19367;
public boolean dhcp = true;
public String gateway = "192.168.0.1";
public String ipaddress = ""; // Example: 192.168.0.46
public String netmask = ""; // Example: 255.255.255.0
public int networkopenduration = 60;
public String proxyaddress = "none";
public int proxyport = 0;
public final Map<String, HueUserAuth> whitelist = new TreeMap<>();
public void makeV1bridge() {
apiversion = "1.16.0";
datastoreversion = "60";
modelid = "BSB001";
swversion = "01041302";
fwversion = "0x262e0500";
}
public void makeV2bridge() {
apiversion = "1.22.0";
datastoreversion = "60";
modelid = "BSB002";
swversion = "1901181309";
fwversion = "0x262e0500";
}
/**
* Return a json serializer that behaves like the default one, but updates the UTC and localtime fields
* before each serializion.
*/
@NonNullByDefault({})
public static class Serializer implements JsonSerializer<HueAuthorizedConfig> {
static class HueAuthorizedConfigHelper extends HueAuthorizedConfig {
}
@Override
public JsonElement serialize(HueAuthorizedConfig src, Type typeOfSrc, JsonSerializationContext context) {
src.UTC = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
src.localtime = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
JsonElement jsonSubscription = context.serialize(src, HueAuthorizedConfigHelper.class);
return jsonSubscription;
}
}
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2020 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.dto;
/**
* Hue Capabilities per endpoint
* Enpoint: /api/{username}/capabilities
* <p>
* https://developers.meethue.com/develop/hue-api/10-capabilities-api/
*
* @author David Graeff - Initial contribution
*/
public class HueCapability {
public int available = 10;
public int total = 10000;
}

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.util.Collections;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Hue data store object. Contains all lights, configuration, user whitelist etc.
* Is used as a data store but also as API DTO.
*
* @author Dan Cunningham - Initial contribution
* @author David Graeff - Add groups,scenes,rules,sensors,resourcelinks and config entries
*
*/
@NonNullByDefault
public class HueDataStore {
public HueAuthorizedConfig config = new HueAuthorizedConfig();
public TreeMap<String, HueLightEntry> lights = new TreeMap<>();
public TreeMap<String, HueGroupEntry> groups = new TreeMap<>();
public Map<String, HueSceneEntry> scenes = new TreeMap<>();
public Map<String, HueRuleEntry> rules = new TreeMap<>();
public Map<String, HueSensorEntry> sensors = new TreeMap<>();
public Map<String, HueScheduleEntry> schedules = new TreeMap<>();
public Map<Integer, Dummy> resourcelinks = Collections.emptyMap();
public Map<String, HueCapability> capabilities = new TreeMap<>();
public HueDataStore() {
resetGroupsAndLights();
capabilities.put("lights", new HueCapability());
capabilities.put("groups", new HueCapability());
capabilities.put("scenes", new HueCapability());
capabilities.put("rules", new HueCapability());
capabilities.put("sensors", new HueCapability());
capabilities.put("schedules", new HueCapability());
capabilities.put("resourcelinks", new HueCapability());
}
public void resetGroupsAndLights() {
groups.clear();
lights.clear();
// There must be a group 0 all the time!
groups.put("0", new HueGroupEntry("All lights", null, null));
}
public void resetSensors() {
sensors.clear();
}
public static class Dummy {
}
/**
* Return a unique group id.
*/
public String nextGroupID() {
int nextId = groups.size();
while (true) {
String id = "hueemulation" + String.valueOf(nextId);
if (!groups.containsKey(id)) {
return id;
}
++nextId;
}
}
}

View File

@@ -0,0 +1,109 @@
/**
* Copyright (c) 2010-2020 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.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;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.annotations.SerializedName;
/**
* Hue API group object
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueGroupEntry {
public static enum TypeEnum {
LightGroup, // 1.4
Luminaire, // 1.4
LightSource, // 1.4
Room, // 1.11
Entertainment, // 1.22
Zone // 1.30
}
public AbstractHueState action = new HueStatePlug();
// The group type
public String type = TypeEnum.LightGroup.name();
// A unique, editable name given to the group.
public String name;
@SerializedName("class")
public String roomclass = "Other";
// The IDs of the lights that are in the group.
public List<String> lights = Collections.emptyList();
public List<String> sensors = Collections.emptyList();
public transient @NonNullByDefault({}) GroupItem groupItem;
public transient @Nullable DeviceType deviceType;
// For deserialisation
HueGroupEntry() {
name = "";
}
public HueGroupEntry(String name, @Nullable GroupItem groupItem, @Nullable DeviceType deviceType) {
this.name = name;
this.groupItem = groupItem;
this.deviceType = deviceType;
}
public void updateItem(GroupItem element) {
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());
}
JsonElement jsonSubscription = context.serialize(product, HueGroupHelper.class);
return jsonSubscription;
}
}
}

View File

@@ -0,0 +1,202 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.lang.reflect.Type;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.GenericItem;
import org.openhab.core.library.items.StringItem;
import org.openhab.core.types.Command;
import org.openhab.io.hueemulation.internal.DeviceType;
import org.openhab.io.hueemulation.internal.StateUtils;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
import com.google.gson.JsonElement;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* Hue API device object
*
* @author Dan Cunningham - Initial contribution
* @author David Graeff - Color lights and plugs
* @author Florian Lentz - XY Support
*/
@NonNullByDefault
public class HueLightEntry {
public AbstractHueState state = new AbstractHueState();
public final String type;
public final String modelid;
public final String uniqueid;
public final String manufacturername;
public final @Nullable String productname;
public final String swversion;
public final @Nullable String luminaireuniqueid = null;
public final @Nullable String swconfigid;
public final @Nullable String productid;
public @Nullable Boolean friendsOfHue = true;
public final @Nullable String colorGamut;
public @Nullable Boolean hascolor = null;
public String name;
/** Associated item UID */
public @NonNullByDefault({}) transient GenericItem item;
public transient DeviceType deviceType;
public transient @Nullable Command lastCommand = null;
public transient @Nullable HueStateChange lastHueChange = null;
public static class Config {
public final String archetype = "classicbulb";
public final String function = "functional";
public final String direction = "omnidirectional";
}
public Config config = new Config();
public static class Streaming {
public boolean renderer = false;
public boolean proxy = false;
}
public static class Capabilities {
public boolean certified = false;
public final Streaming streaming = new Streaming();
public final Object control = new Object();
}
public Capabilities capabilities = new Capabilities();
private HueLightEntry() {
this(new StringItem(""), "", DeviceType.SwitchType);
}
/**
* 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
*/
public HueLightEntry(GenericItem item, String uniqueid, DeviceType deviceType) {
String label = item.getLabel();
this.item = item;
this.deviceType = deviceType;
this.uniqueid = uniqueid;
switch (deviceType) {
case ColorType:
this.name = label != null ? label : "";
this.type = "Extended Color light";
this.modelid = "LCT010";
this.colorGamut = "C";
this.manufacturername = "Philips";
this.swconfigid = "F921C859";
this.swversion = "1.15.2_r19181";
this.productid = "Philips-LCT010-1-A19ECLv4";
this.productname = null;
this.hascolor = true;
this.capabilities.certified = true;
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;
this.manufacturername = "Philips";
this.swconfigid = null;
this.swversion = "66012040";
this.productid = null;
this.hascolor = false;
this.productname = null;
this.capabilities.certified = true;
break;
case WhiteTemperatureType:
this.name = label != null ? label : "";
this.type = "Color temperature light";
this.modelid = "LTW001";
this.colorGamut = "2200K-6500K";
this.manufacturername = "Philips";
this.swconfigid = null;
this.swversion = "66012040";
this.productid = null;
this.hascolor = false;
this.productname = null;
this.capabilities.certified = true;
break;
case SwitchType:
default:
/**
* 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;
this.manufacturername = "OSRAM";
this.productname = "On/Off plug";
this.swconfigid = null;
this.swversion = "V1.04.12";
this.productid = null;
this.hascolor = false;
this.friendsOfHue = null;
break;
}
state = StateUtils.colorStateFromItemState(item.getState(), deviceType);
}
/**
* This custom serializer updates the light state and label, before serializing.
*/
@NonNullByDefault({})
public static class Serializer implements JsonSerializer<HueLightEntry> {
static class HueDeviceHelper extends HueLightEntry {
}
@Override
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;
}
JsonElement jsonSubscription = context.serialize(product, HueDeviceHelper.class);
return jsonSubscription;
}
}
/**
* Replaces the associated openHAB item of this hue device with the given once
* and also synchronizes/updates the color information of this hue device with the item.
*
* @param element A replace item
*/
public void updateItem(GenericItem element) {
item = element;
state = StateUtils.colorStateFromItemState(item.getState(), deviceType);
lastCommand = null;
lastHueChange = null;
String label = element.getLabel();
if (label != null) {
name = label;
}
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2020 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.dto;
/**
* Hue API scan result object.
* Enpoint: /api/{username}/lights/new
*
* @author David Graeff - Initial contribution
*/
public class HueNewLights {
enum ScanType {
none,
active
}
/** Either none, active or a timestamp since the last search */
public String lastscan = ScanType.none.name();
}

View File

@@ -0,0 +1,139 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* Hue API rule object
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueRuleEntry {
// A unique, editable name given to the group.
public String name = "";
public String description = "";
public String owner = "";
public static enum Operator {
unknown,
eq, // equals, Used for bool and int.
gt, // greater than, Allowed on int values.
lt, // less than, Allowed on int values.
dx, // value has changed, Time (timestamps) int and bool values. Only dx or ddx is allowed, but not both.
ddx, // delayed value has changed
stable, // Time (timestamps) int and bool values. An attribute has or has not changed for a given time.
not_stable,
in, // Current time is in or not in given time interval (only for /config/localtime, not UTC). “in” rule will be
// triggered on starttime and “not in” rule will be triggered on endtime. Only one “in” operator is allowed
// in a rule. Multiple “not in” operators are allowed in a rule.
not_in
}
/**
* A complete condition could look like this:
* <p>
*
* <pre>
* {
* "address": "/sensors/2/state/buttonevent",
* "operator": "eq",
* "value": "16"
* },
* </pre>
*/
public static class Condition {
/**
* A hue resource address like "/config/localtime" or "/sensors/2/state/buttonevent"
*/
public String address = "";
public Operator operator = Operator.unknown;
/**
* A value like "16"
*/
public @Nullable String value;
public Condition() {
}
public Condition(String address, Operator operator, @Nullable String value) {
this.address = address;
this.value = value;
this.operator = operator;
}
}
// The IDs of the lights that are in the group.
public List<Condition> conditions = new ArrayList<>();
public List<HueCommand> actions = new ArrayList<>();
HueRuleEntry() {
name = "";
}
public HueRuleEntry(@Nullable String name) {
this.name = name != null ? name : "";
}
/**
* This custom serializer (de)serialize the Condition class and translates between the enum values that have
* an underbar and the json strings that have a whitespace instead.
*/
@NonNullByDefault({})
public static class SerializerCondition
implements JsonSerializer<HueRuleEntry.Condition>, JsonDeserializer<HueRuleEntry.Condition> {
@Override
public JsonElement serialize(HueRuleEntry.Condition product, Type type, JsonSerializationContext context) {
JsonObject jObj = new JsonObject();
jObj.addProperty("address", product.address);
String value = product.value;
if (value != null) {
jObj.addProperty("value", value);
}
jObj.addProperty("operator", product.operator.name().replace("_", " "));
return jObj;
}
@Override
public Condition deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
JsonObject jObj = json.getAsJsonObject();
Condition c = new Condition();
c.address = jObj.get("address").getAsString();
if (jObj.has("value")) {
c.value = jObj.get("value").getAsString();
}
String operator = jObj.get("operator").getAsString().replace(" ", "_");
c.operator = Operator.valueOf(operator);
return c;
}
}
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Hue API scene object
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueSceneEntry {
public static enum TypeEnum {
LightScene, // 1.28
GroupScene, // 1.28
}
public TypeEnum type = TypeEnum.LightScene;
// A unique, editable name given to the group.
public String name;
public String description = "";
public String owner = "";
public boolean recycle = false;
public boolean locked = false;
final int version = 2;
public String appdata = "";
public String picture = "";
// The IDs of the lights that are in the group.
public @Nullable List<String> lights;
public @Nullable String group;
HueSceneEntry() {
name = "";
}
public HueSceneEntry(@Nullable String name) {
this.name = name != null ? name : "";
}
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Hue API scene object with light states
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueSceneWithLightstates extends HueSceneEntry {
public Map<String, AbstractHueState> lightstates = new TreeMap<>();
HueSceneWithLightstates() {
super();
}
public HueSceneWithLightstates(HueSceneEntry e) {
this.type = e.type;
this.name = e.name;
this.description = e.description;
this.owner = e.owner;
this.recycle = e.recycle;
this.locked = e.locked;
this.appdata = e.appdata;
this.picture = e.picture;
this.lights = e.lights;
this.group = e.group;
}
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2020 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.dto;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeScheduleEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
/**
* Hue API scan result object.
* Enpoint: /api/{username}/lights/new
*
* @author David Graeff - Initial contribution
*/
public class HueScheduleEntry extends HueChangeScheduleEntry {
/**
* Assign default values to all fields that are otherwise nullable by the {@link HueChangeScheduleEntry}.
*/
public HueScheduleEntry() {
command = new HueCommand();
name = "";
description = "";
localtime = "";
status = "disabled";
recycle = false;
autodelete = true;
}
}

View File

@@ -0,0 +1,147 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.lang.reflect.Type;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.GenericItem;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.types.State;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* Hue API scene object
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueSensorEntry {
// A unique, editable name given to the group.
public String name;
public String type;
public String modelid;
public String manufacturername = "openHab";
public String swversion = "1.0";
public Object config = new Object();
public String uniqueid;
public final @NonNullByDefault({}) transient GenericItem item;
private HueSensorEntry() {
item = null;
name = "";
type = "";
modelid = "";
uniqueid = "";
}
public HueSensorEntry(GenericItem item) throws IllegalArgumentException {
this.item = item;
String label = item.getLabel();
this.name = label != null ? label : item.getName();
this.modelid = "openHAB_" + item.getType();
this.uniqueid = item.getUID();
switch (item.getType()) {
case CoreItemFactory.CONTACT:
this.type = "CLIPOpenClose"; // "open"
break;
case CoreItemFactory.ROLLERSHUTTER:
case CoreItemFactory.DIMMER:
this.type = "CLIPGenericStatus"; // "status" (int)
break;
case CoreItemFactory.NUMBER:
if (item.hasTag("temperature")) {
this.type = "CLIPTemperature"; // "temperature"
} else {
this.type = "CLIPLightLevel"; // "lightlevel" (int), "dark" (bool), "daylight" (bool)
}
break;
case CoreItemFactory.COLOR:
this.type = "CLIPLightLevel"; // "lightlevel" (int), "dark" (bool), "daylight" (bool)
break;
case CoreItemFactory.SWITCH:
this.type = "CLIPGenericFlag"; // "flag" (bool)
break;
default:
throw new IllegalArgumentException("Item type not supported as sensor");
}
}
/**
* 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<HueSensorEntry> {
static class HueHelper extends HueSensorEntry {
}
@Override
public JsonElement serialize(HueSensorEntry product, Type type, JsonSerializationContext context) {
JsonElement json = context.serialize(product, HueHelper.class);
JsonObject state = new JsonObject();
State itemState = product.item.getState();
switch (product.type) {
case "CLIPOpenClose":
if (itemState instanceof OpenClosedType) {
state.addProperty("open", ((OpenClosedType) product.item.getState()) == OpenClosedType.OPEN);
} else {
state.addProperty("open", false);
}
break;
case "CLIPGenericStatus":
if (itemState instanceof DecimalType) {
state.addProperty("status", ((DecimalType) product.item.getState()).intValue());
} else {
state.addProperty("status", 0);
}
break;
case "CLIPTemperature":
if (itemState instanceof DecimalType) {
state.addProperty("temperature", ((DecimalType) product.item.getState()).intValue());
} else {
state.addProperty("status", 0);
}
break;
case "CLIPLightLevel":
if (itemState instanceof DecimalType) {
state.addProperty("lightlevel", ((DecimalType) product.item.getState()).intValue());
} else {
state.addProperty("status", 0);
}
state.addProperty("dark", false);
state.addProperty("daylight", false);
break;
case "CLIPGenericFlag":
if (itemState instanceof OnOffType) {
state.addProperty("flag", ((OnOffType) product.item.getState()) == OnOffType.ON);
} else {
state.addProperty("open", false);
}
break;
}
json.getAsJsonObject().add("state", state);
return json;
}
}
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) 2010-2020 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.dto;
import org.openhab.core.library.types.PercentType;
/**
* Hue API state object
*
* @author Dan Cunningham - Initial contribution
* @author David Graeff - "extended color light bulbs" support
*
*/
public class HueStateBulb extends HueStatePlug {
// https://github.com/openhab/openhab-addons/issues/2881
// Apparently the maximum brightness is 254
public static int MAX_BRI = 254;
public int bri = 0;
/** white color temperature, 154 (cold) - 500 (warm) */
public static int MAX_CT = 500;
public int ct = 500;
protected HueStateBulb() {
}
public HueStateBulb(boolean on) {
super(on);
this.bri = on ? MAX_BRI : 1;
}
/**
* Create a hue state with the given brightness percentage
*
* @param brightness Brightness percentage
* @param on On value
*/
public HueStateBulb(PercentType brightness, boolean on) {
super(on);
this.bri = Math.max(1, (int) (brightness.intValue() * MAX_BRI / 100.0 + 0.5));
}
public PercentType toBrightnessType() {
int bri = this.bri * 100 / MAX_BRI;
if (!this.on) {
bri = 0;
}
return new PercentType(bri);
}
@Override
public String toString() {
return "on: " + on + ", brightness: " + bri + ", reachable: " + reachable;
}
}

View File

@@ -0,0 +1,161 @@
/**
* Copyright (c) 2010-2020 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.dto;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.PercentType;
/**
* Hue API state object
*
* @author Dan Cunningham - Initial contribution
* @author David Graeff - "extended color light bulbs" support
* @author Florian Lentz - added xy support
*
*/
public class HueStateColorBulb extends HueStateBulb {
public static int MAX_HUE = 65535; // For extended color light bulbs
public int hue = 0;
public static int MAX_SAT = 254;
public int sat = 0;
// color as array of xy-coordinates
public double[] xy = { 0, 0 };
public String effect = "none";
/** time for transition in centiseconds. */
public int transitiontime;
public static enum ColorMode {
ct,
hs,
xy
}
public ColorMode colormode = ColorMode.ct;
protected HueStateColorBulb() {
}
public HueStateColorBulb(boolean on) {
super(on);
colormode = ColorMode.ct;
}
/**
* Create a hue state with the given brightness percentage
*
* @param brightness Brightness percentage
* @param on On value
*/
public HueStateColorBulb(PercentType brightness, boolean on) {
super(brightness, on);
colormode = ColorMode.ct;
}
/**
* Creates a hue state with the given color information
*
* @param hsb Color information. Sets the hue state to "on" if brightness is > 0.
*/
public HueStateColorBulb(HSBType hsb) {
super(hsb.getBrightness(), hsb.getBrightness().intValue() > 0);
this.hue = (int) (hsb.getHue().intValue() * MAX_HUE / 360.0 + 0.5);
this.sat = (int) (hsb.getSaturation().intValue() * MAX_SAT / 100.0 + 0.5);
colormode = this.sat > 0 ? ColorMode.hs : ColorMode.ct;
}
/**
* Converts this HueState to a HSBType
*/
public HSBType toHSBType() {
if (colormode == ColorMode.xy) {
int i;
double y = (this.bri) / 100.0d;
double x = (y / this.xy[1]) * this.xy[0];
double z = (y / this.xy[1]) * ((1.0d - this.xy[0]) - this.xy[1]);
int r = (int) (Math.abs(
((1.4628067016601562d * x) - (0.18406230211257935d * y)) - (0.2743605971336365d * z)) * 255.0d);
int g = (int) (Math.abs(
(((-x) * 0.5217933058738708d) + (1.4472380876541138d * y)) + (0.06772270053625107d * z)) * 255.0d);
int b = (int) (Math.abs(
((0.03493420034646988d * x) - (0.09689299762248993d * y)) + (1.288409948348999d * z)) * 255.0d);
if (r < g) {
i = r;
} else {
i = g;
}
double minValue = i;
if (minValue >= b) {
minValue = b;
}
double maxValue = (r > g ? r : g);
if (maxValue <= b) {
maxValue = b;
}
double delta = maxValue - minValue;
if (maxValue <= 0.0d) {
return new HSBType(new DecimalType(0), new PercentType(100),
new PercentType((this.bri * 100) / MAX_BRI));
}
double h;
if ((r) >= maxValue) {
h = (g - b) / delta;
} else if ((g) >= maxValue) {
h = 2.0d + ((b - r) / delta);
} else {
h = 4.0d + ((r - g) / delta);
}
h *= 60.0d;
if (h < 0.0d) {
h += 360.0d;
}
double hueSat = Math.floor((delta / maxValue) * 254.0d);
int percentSat = (int) ((100.0d * hueSat) / (MAX_SAT));
int bri = this.bri * 100 / MAX_BRI;
if (!this.on) {
bri = 0;
}
return new HSBType(new DecimalType((Math.floor(182.04d * h) * 360.0d) / (MAX_HUE)),
new PercentType(percentSat), new PercentType(bri));
} else {
int bri = this.bri * 100 / MAX_BRI;
int sat = this.sat * 100 / MAX_SAT;
int hue = this.hue * 360 / MAX_HUE;
if (!this.on) {
bri = 0;
}
if (colormode == ColorMode.ct) {
sat = 0;
}
return new HSBType(new DecimalType(hue), new PercentType(sat), new PercentType(bri));
}
}
@Override
public String toString() {
String xyString = "{";
for (double d : xy) {
xyString += d + " ";
}
xyString += "}";
return "on: " + on + ", brightness: " + bri + ", hue: " + hue + ", sat: " + sat + ", xy: " + xyString + ", ct: "
+ ct + ", alert: " + alert + ", effect: " + effect + ", colormode: " + colormode + ", reachable: "
+ reachable;
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2010-2020 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.dto;
import org.openhab.core.library.types.OnOffType;
/**
* Hue API state object for plugs
*
* @author David Graeff - Initial contribution
*
*/
public class HueStatePlug extends AbstractHueState {
public boolean on;
protected HueStatePlug() {
}
public HueStatePlug(boolean on) {
this.on = on;
}
public OnOffType toOnOffType() {
return on ? OnOffType.ON : OnOffType.OFF;
}
@Override
public String toString() {
return "on: " + on + ", reachable: " + reachable;
}
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2020 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.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Hue API config object. Also accessible for non-authorized users.
* Enpoint: /api/config
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueUnauthorizedConfig {
public String apiversion = "1.18.0";
public String bridgeid = ""; // Example: 00212EFFFF022F6E
public String datastoreversion = "60";
public String starterkitid = "";
public String modelid = "BSB002";
public String name = "openHAB Devices";
public String swversion = "2.5.46";
public String mac = "b8:27:eb:1d:d8:0c";
public boolean factorynew = false;
public @Nullable String replacesbridgeid = null;
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2010-2020 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.dto;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Hue user object. Used by {@link HueAuthorizedConfig}.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueUserAuth {
public String name = "";
public String createDate = "";
public String lastUseDate = "";
public String clientKey = "";
/**
* For de-serialization.
*/
HueUserAuth() {
}
/**
* Create a new user
*
* @param apikey The hue "username"
*/
public HueUserAuth(String appName, String deviceName) {
this.name = appName + "#" + deviceName;
this.createDate = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2020 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.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.common.registry.Identifiable;
/**
* Hue user object. This is stored on disk for hue users, but not send via the rest api
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueUserAuthWithSecrets extends HueUserAuth implements Identifiable<String> {
public String apiKey = "";
/**
* For de-serialization.
*/
HueUserAuthWithSecrets() {
}
/**
* Create a new user with credentials
*/
public HueUserAuthWithSecrets(String appName, String deviceName, String apiKey, String clientKey) {
super(appName, deviceName);
this.apiKey = apiKey;
this.clientKey = clientKey;
}
@Override
public String getUID() {
return apiKey;
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2020 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.dto.changerequest;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Multiple endpoints support PUT changes, for example:
* <ul>
* <li>Config: Allows to change the bridge name, dhcp, portalservices, linkbutton
* <li>Light: Allows to change the name
* <li>Group: Allows to change the name
* <li>Sensor: Allows to change the name
* </ul>
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueChangeRequest {
public @Nullable String devicename;
public @Nullable String name;
public @Nullable Boolean dhcp;
public @Nullable Boolean linkbutton;
public @Nullable Boolean portalservices;
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2020 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.dto.changerequest;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Hue API scan result object.
* Enpoint: /api/{username}/scenes/ab341ef24/lights/1/state
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueChangeSceneEntry {
public @Nullable String name;
public @Nullable String description;
public boolean storelightstate = false;
public @Nullable List<String> lights;
public @Nullable Map<String, HueStateChange> lightstates;
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2020 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.dto.changerequest;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Hue API scan result object.
* Enpoint: /api/{username}/lights/new
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueChangeScheduleEntry {
public @Nullable String name;
public @Nullable String description;
public @Nullable String localtime;
// Either "enabled" or "disabled"
public @Nullable String status;
public @Nullable Boolean autodelete;
public @Nullable Boolean recycle;
public @Nullable HueCommand command;
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2010-2020 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.dto.changerequest;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Commands are http request data (method+url+body) used by schedules and rules.
* <p>
* Note: Rules use shorter address variants without the "/api/username" parts, which
* makes them no valid relative urls!
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueCommand {
public String address = "";
public String method = "";
public String body = "";
public boolean isValid() {
return !address.isEmpty() && !method.isEmpty() && !body.isEmpty();
}
public HueCommand() {
}
public HueCommand(String address, String method, String body) {
this.address = address;
this.method = method;
this.body = body;
}
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2020 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.dto.changerequest;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Hue API create user object
*
* @author Dan Cunningham - Initial contribution
* @author David Graeff - Rewritten
*/
@NonNullByDefault
public class HueCreateUser {
/** ApplicationName#DeviceName */
public String devicetype = "";
}

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2020 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.dto.changerequest;
import java.util.List;
/**
* A POST message on a light entpoint will contain this change message body.
* Not all fields will be set and always need to be checked.
*
* @author David Graeff - Initial contribution
*/
public class HueStateChange {
public Boolean on;
public Integer bri;
public Integer hue;
public Integer sat;
public String effect;
public Integer ct;
public String alert;
public List<Double> xy;
public Integer transitiontime;
public Integer bri_inc;
public Integer hue_inc;
public Integer sat_inc;
public List<Double> xy_inc;
public Integer ct_inc;
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Hue API response base type
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueResponse {
public static final int UNAUTHORIZED = 1;
public static final int INVALID_JSON = 2;
public static final int NOT_AVAILABLE = 3;
public static final int METHOD_NOT_ALLOWED = 4;
public static final int ARGUMENTS_INVALID = 7;
public static final int SENSOR_NOT_CLIP_SENSOR = 8;
public static final int LINK_BUTTON_NOT_PRESSED = 101;
public static final int INTERNAL_ERROR = 901;
public static final int RULE_ENGINE_FULL = 601; // The Rule Engine has reached its maximum capacity of 100 rules.
public static final int CONDITION_ERROR = 607; // Rule conditions contain errors or operator combination is not
// allowed
public static final int ACTION_ERROR = 608; // Rule actions contain errors or multiple actions with the same
// resource address.
public static final int TOO_MANY_ITEMS = 11; // Too many items in the list (too many conditions or too many actions)
public final @Nullable HueErrorMessage error;
public final @Nullable HueSuccessResponse success;
public HueResponse(HueErrorMessage error) {
this.error = error;
this.success = null;
}
public HueResponse(HueSuccessResponse success) {
this.error = null;
this.success = success;
}
public static class HueErrorMessage {
public final String address;
public final String description;
public final int type;
public HueErrorMessage(int type, String address, String description) {
this.type = type;
this.address = address;
this.description = description;
}
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Hue API response base type
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueResponseSuccessSimple {
public final String success;
public HueResponseSuccessSimple(String success) {
this.success = success;
}
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
/**
* This object describes the right hand side of "success".
* The response looks like this:
*
* <pre>
* {
* "success":{
* "id": "-the-id-"
* }
* }
* </pre>
*
* @author David Graeff - Initial contribution
*/
public class HueSuccessCreateGroup extends HueSuccessResponse {
public String id;
public HueSuccessCreateGroup(String id) {
this.id = id;
}
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
import java.lang.reflect.Type;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* This object describes the right hand side of "success".
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueSuccessGeneric extends HueSuccessResponse {
public String message;
public transient String key;
public HueSuccessGeneric(@Nullable Object message, String key) {
this.message = message != null ? String.valueOf(message) : "";
this.key = key;
}
public static class Serializer implements JsonSerializer<HueSuccessGeneric> {
@NonNullByDefault({})
@Override
public JsonElement serialize(HueSuccessGeneric product, Type type, JsonSerializationContext jsc) {
JsonObject jObj = new JsonObject();
jObj.addProperty(product.key, product.message);
return jObj;
}
}
public boolean isValid() {
return !message.isEmpty();
}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
/**
* Hue API response success base type
*
* @author David Graeff - Initial contribution
*/
public class HueSuccessResponse {
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This object describes the right hand side of "success".
* The response looks like this:
*
* <pre>
* {
* "success":{
* "username": "-the-username-"
* }
* }
* </pre>
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HueSuccessResponseCreateUser extends HueSuccessResponse {
public String username;
// For DTLS setup of the new hue entertain DTLS/UDP protocol
// The PSK identity matches the “username”, and the PSK key matches the “clientkey”.
public String clientkey;
public HueSuccessResponseCreateUser(String username, String clientkey) {
this.username = username;
this.clientkey = clientkey;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2010-2020 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.dto.response;
import java.lang.reflect.Type;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* This response object is a bit complicated. The response is required to look like this:
*
* <pre>
* {
* "success":{
* "/1d49eeed-1fa7-434a-8e6c-70bb2cdc3e8f/lights/1/state/on": true
* }
* }
* </pre>
*
* This object describes the right hand side of "success". The json key itself is the uri path
* and the value is either a boolean, number or string.
*
* This is done with a custom serializer that creates the proper {@link JsonObject}.
*
* @author David Graeff - Initial contribution
*/
public class HueSuccessResponseStateChanged extends HueSuccessResponse {
private transient Object value;
private transient String relURI;
public HueSuccessResponseStateChanged(String relURI, Object value) {
this.relURI = relURI;
this.value = value;
}
public static class Serializer implements JsonSerializer<HueSuccessResponseStateChanged> {
@Override
public JsonElement serialize(HueSuccessResponseStateChanged product, Type type, JsonSerializationContext jsc) {
JsonObject jObj = new JsonObject();
if (product.value instanceof Float) {
jObj.addProperty(product.relURI, (Float) product.value);
}
if (product.value instanceof Double) {
jObj.addProperty(product.relURI, (Double) product.value);
}
if (product.value instanceof Integer) {
jObj.addProperty(product.relURI, (Integer) product.value);
}
if (product.value instanceof Boolean) {
jObj.addProperty(product.relURI, (Boolean) product.value);
}
if (product.value instanceof String) {
jObj.addProperty(product.relURI, (String) product.value);
}
return jObj;
}
}
}

View File

@@ -0,0 +1,135 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.util.Collections;
import java.util.List;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.dto.HueUnauthorizedConfig;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeRequest;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse.HueErrorMessage;
import org.osgi.service.cm.ConfigurationAdmin;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import com.google.gson.reflect.TypeToken;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = {
ConfigurationAccess.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
public class ConfigurationAccess {
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) ConfigurationAdmin configAdmin;
@GET
@Path("config")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return the reduced configuration")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getReducedConfigApi() {
return Response.ok(cs.gson.toJson(cs.ds.config, new TypeToken<HueUnauthorizedConfig>() {
}.getType())).build();
}
@GET
@Path("{username}")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return the full data store")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getAllApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds)).build();
}
@GET
@Path("{username}/config")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return the configuration")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getFullConfigApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.config)).build();
}
@PUT
@Path("{username}/config")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return the reduced configuration")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response putFullConfigApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
final HueChangeRequest changes;
changes = cs.gson.fromJson(body, HueChangeRequest.class);
String devicename = changes.devicename;
if (devicename != null) {
cs.ds.config.devicename = devicename;
}
Boolean dhcp = changes.dhcp;
if (dhcp != null) {
cs.ds.config.dhcp = dhcp;
}
Boolean linkbutton = changes.linkbutton;
if (linkbutton != null) {
cs.setLinkbutton(linkbutton, cs.getConfig().createNewUserOnEveryEndpoint,
cs.getConfig().temporarilyEmulateV1bridge);
}
return Response.ok(cs.gson.toJson(cs.ds.config)).build();
}
@Path("{username}/{var:.+}")
@Produces(MediaType.APPLICATION_JSON)
public Response catchAll(@Context UriInfo uri) {
HueResponse e = new HueResponse(
new HueErrorMessage(HueResponse.INVALID_JSON, uri.getPath().replace("/api", ""), "Invalid request: "));
String str = cs.gson.toJson(Collections.singleton(e), new TypeToken<List<?>>() {
}.getType());
return Response.status(404).entity(str).build();
}
}

View File

@@ -0,0 +1,530 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.events.EventPublisher;
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.items.events.ItemEventFactory;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.types.Command;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.DeviceType;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.StateUtils;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueNewLights;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeRequest;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.reflect.TypeToken;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* Listens to the ItemRegistry for items that fulfill one of these criteria:
* <ul>
* <li>Type is any of SWITCH, DIMMER, COLOR, or Group
* <li>The category is "ColorLight" for coloured lights or "Light" for switchables.
* <li>The item is tagged, according to what is set with {@link #setFilterTags(Set, Set, Set)}.
* </ul>
*
* <p>
* A {@link HueLightEntry} instances is created for each found item.
* Those are kept in the given {@link org.openhab.io.hueemulation.internal.dto.HueDataStore}.
* </p>
*
* <p>
* The HUE Rest API requires a unique string based ID for every listed light.
* We are using item names here. Not all hue clients might be compatible with non
* numeric Ics.ds. A solution could be an ItemMetaData provider and to store a
* generated integer id for each item.
* </p>
*
* <p>
* </p>
*
* @author David Graeff - Initial contribution
* @author Florian Schmidt - Removed base type restriction from Group items
*/
@Component(immediate = false, service = { LightsAndGroups.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
public class LightsAndGroups implements 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";
private static final Set<String> ALLOWED_ITEM_TYPES = Stream.of(CoreItemFactory.COLOR, CoreItemFactory.DIMMER,
CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH, ITEM_TYPE_GROUP).collect(Collectors.toSet());
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
@Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
protected volatile @Nullable EventPublisher eventPublisher;
/**
* Registers to the {@link ItemRegistry} and enumerates currently existing items.
*/
@Activate
protected void activate() {
cs.ds.resetGroupsAndLights();
itemRegistry.removeRegistryChangeListener(this);
itemRegistry.addRegistryChangeListener(this);
for (Item item : itemRegistry.getItems()) {
added(item);
}
}
/**
* Unregisters from the {@link ItemRegistry}.
*/
@Deactivate
protected void deactivate() {
itemRegistry.removeRegistryChangeListener(this);
}
@Override
public synchronized void added(Item newElement) {
if (!(newElement instanceof GenericItem)) {
return;
}
GenericItem element = (GenericItem) newElement;
if (!(element instanceof GroupItem) && !ALLOWED_ITEM_TYPES.contains(element.getType())) {
return;
}
DeviceType deviceType = StateUtils.determineTargetType(cs, element);
if (deviceType == null) {
return;
}
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);
// Restore group type and room class from tags
for (String tag : g.getTags()) {
if (tag.startsWith("huetype_")) {
group.type = tag.split("huetype_")[1];
} else if (tag.startsWith("hueroom_")) {
group.roomclass = tag.split("hueroom_")[1];
}
}
// Add group members
group.lights = new ArrayList<>();
for (Item item : g.getMembers()) {
group.lights.add(cs.mapItemUIDtoHueID(item));
}
cs.ds.groups.put(hueID, group);
} else {
HueLightEntry device = new HueLightEntry(element, cs.ds.config.uuid + "-" + hueID.toString(), deviceType);
device.item = element;
cs.ds.lights.put(hueID, device);
updateGroup0();
}
}
/**
* The HUE API enforces a Group 0 that contains all lights.
*/
private void updateGroup0() {
cs.ds.groups.get("0").lights = cs.ds.lights.keySet().stream().map(v -> String.valueOf(v))
.collect(Collectors.toList());
}
@Override
public synchronized void removed(Item element) {
String hueID = cs.mapItemUIDtoHueID(element);
logger.debug("Remove item {}", hueID);
cs.ds.lights.remove(hueID);
cs.ds.groups.remove(hueID);
updateGroup0();
}
/**
* The tags might have changed
*/
@SuppressWarnings({ "null", "unused" })
@Override
public synchronized void updated(Item oldElement, Item newElement) {
if (!(newElement instanceof GenericItem)) {
return;
}
GenericItem element = (GenericItem) newElement;
String hueID = cs.mapItemUIDtoHueID(element);
HueGroupEntry hueGroup = cs.ds.groups.get(hueID);
if (hueGroup != null) {
DeviceType t = StateUtils.determineTargetType(cs, element);
if (t != null && element instanceof GroupItem) {
hueGroup.updateItem((GroupItem) element);
} else {
cs.ds.groups.remove(hueID);
}
}
HueLightEntry hueDevice = cs.ds.lights.get(hueID);
if (hueDevice == null) {
// If the correct tags got added -> use the logic within added()
added(element);
return;
}
// Check if type can still be determined (tags and category is still sufficient)
DeviceType t = StateUtils.determineTargetType(cs, element);
if (t == null) {
removed(element);
return;
}
hueDevice.updateItem(element);
}
@GET
@Path("{username}/lights")
@ApiOperation(value = "Return all lights")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getAllLightsApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.lights)).build();
}
@GET
@Path("{username}/lights/new")
@ApiOperation(value = "Return new lights since last scan. Returns an empty list for openHAB as we do not cache that information.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getNewLights(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(new HueNewLights())).build();
}
@POST
@Path("{username}/lights")
@ApiOperation(value = "Starts a new scan for compatible items. This is usually not necessary, because we are observing the item registry.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response postNewLights(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return NetworkUtils.singleSuccess(cs.gson, "Searching for new devices", "/lights");
}
@GET
@Path("{username}/lights/{id}")
@ApiOperation(value = "Return a light")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getLightApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "light id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.lights.get(id))).build();
}
@SuppressWarnings({ "null", "unused" })
@DELETE
@Path("{username}/lights/{id}")
@ApiOperation(value = "Deletes the item that is represented by this id")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The item got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeLightAPI(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueLightEntry hueDevice = cs.ds.lights.get(id);
if (hueDevice == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light does not exist");
}
if (itemRegistry.remove(id) != null) {
return NetworkUtils.singleSuccess(cs.gson, "/lights/" + id + " deleted.");
} else {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light does not exist");
}
}
@SuppressWarnings({ "null", "unused" })
@PUT
@Path("{username}/lights/{id}")
@ApiOperation(value = "Rename a light")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response renameLightApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "light id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueLightEntry hueDevice = cs.ds.lights.get(id);
if (hueDevice == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light not existing");
}
final HueChangeRequest changeRequest = cs.gson.fromJson(body, HueChangeRequest.class);
String name = changeRequest.name;
if (name == null || name.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON, "Invalid request: No name set");
}
hueDevice.item.setLabel(name);
itemRegistry.update(hueDevice.item);
return NetworkUtils.singleSuccess(cs.gson, name, "/lights/" + id + "/name");
}
@SuppressWarnings({ "null", "unused" })
@PUT
@Path("{username}/lights/{id}/state")
@ApiOperation(value = "Set light state")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response setLightStateApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "light id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueLightEntry hueDevice = cs.ds.lights.get(id);
if (hueDevice == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light not existing");
}
HueStateChange newState = cs.gson.fromJson(body, HueStateChange.class);
if (newState == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No state change data received!");
}
hueDevice.state = StateUtils.colorStateFromItemState(hueDevice.item.getState(), hueDevice.deviceType);
String itemUID = hueDevice.item.getUID();
List<HueResponse> responses = new ArrayList<>();
Command command = StateUtils.computeCommandByState(responses, "/lights/" + id + "/state", hueDevice.state,
newState);
// If a command could be created, post it to the framework now
if (command != null) {
EventPublisher localEventPublisher = eventPublisher;
if (localEventPublisher != null) {
logger.debug("sending {} to {}", command, itemUID);
localEventPublisher.post(ItemEventFactory.createCommandEvent(itemUID, command, "hueemulation"));
} else {
logger.warn("No event publisher. Cannot post item '{}' command!", itemUID);
}
hueDevice.lastCommand = command;
hueDevice.lastHueChange = newState;
}
return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
}.getType())).build();
}
@SuppressWarnings({ "null", "unused" })
@PUT
@Path("{username}/groups/{id}/action")
@ApiOperation(value = "Initiate group action")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response setGroupActionApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "group id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueGroupEntry hueDevice = cs.ds.groups.get(id);
GroupItem groupItem = hueDevice.groupItem;
if (hueDevice == null || groupItem == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group not existing");
}
HueStateChange state = cs.gson.fromJson(body, HueStateChange.class);
if (state == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No state change data received!");
}
// First synchronize the internal state information with the framework
hueDevice.action = StateUtils.colorStateFromItemState(groupItem.getState(), hueDevice.deviceType);
List<HueResponse> responses = new ArrayList<>();
Command command = StateUtils.computeCommandByState(responses, "/groups/" + id + "/state/", hueDevice.action,
state);
// If a command could be created, post it to the framework now
if (command != null) {
logger.debug("sending {} to {}", command, id);
EventPublisher localEventPublisher = eventPublisher;
if (localEventPublisher != null) {
localEventPublisher
.post(ItemEventFactory.createCommandEvent(groupItem.getUID(), command, "hueemulation"));
} else {
logger.warn("No event publisher. Cannot post item '{}' command!", groupItem.getUID());
}
}
return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
}.getType())).build();
}
@GET
@Path("{username}/groups")
@ApiOperation(value = "Return all groups")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getAllGroupsApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.groups)).build();
}
@GET
@Path("{username}/groups/{id}")
@ApiOperation(value = "Return a group")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getGroupApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "group id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.groups.get(id))).build();
}
@SuppressWarnings({ "null", "unused" })
@POST
@Path("{username}/groups")
@ApiOperation(value = "Create a new group")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response postNewGroup(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueGroupEntry state = cs.gson.fromJson(body, HueGroupEntry.class);
if (state == null || state.name.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No state change data received!");
}
String groupid = cs.ds.nextGroupID();
GroupItem groupItem = new GroupItem(groupid);
if (!HueGroupEntry.TypeEnum.LightGroup.name().equals(state.type)) {
groupItem.addTag("huetype_" + state.type);
}
if (HueGroupEntry.TypeEnum.Room.name().equals(state.type) && !state.roomclass.isEmpty()) {
groupItem.addTag("hueroom_" + state.roomclass);
}
List<Item> groupItems = new ArrayList<>();
for (String id : state.lights) {
Item item = itemRegistry.get(id);
if (item == null) {
logger.debug("Could not create group {}. Item {} not existing!", state.name, id);
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID,
"Invalid request: Item not existing");
}
groupItem.addMember(item);
}
itemRegistry.add(groupItem);
return NetworkUtils.singleSuccess(cs.gson, groupid, "id");
}
@SuppressWarnings({ "null", "unused" })
@DELETE
@Path("{username}/groups/{id}")
@ApiOperation(value = "Deletes the item that is represented by this id")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The item got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeGroupAPI(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueLightEntry hueDevice = cs.ds.lights.get(id);
if (hueDevice == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");
}
if (itemRegistry.remove(id) != null) {
return NetworkUtils.singleSuccess(cs.gson, "/groups/" + id + " deleted.");
} else {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");
}
}
}

View File

@@ -0,0 +1,371 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.Condition;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.util.ModuleBuilder;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.RuleUtils;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* Handles Hue rules via the automation subsystem and the corresponding REST interface
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { Rules.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class Rules implements RegistryChangeListener<Rule> {
public static final String RULES_TAG = "hueemulation_rule";
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
@Reference
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
/**
* Registers to the {@link RuleRegistry} and enumerates currently existing rules.
*/
@Activate
public void activate() {
ruleRegistry.removeRegistryChangeListener(this);
ruleRegistry.addRegistryChangeListener(this);
for (Rule item : ruleRegistry.getAll()) {
added(item);
}
}
@Deactivate
public void deactivate() {
ruleRegistry.removeRegistryChangeListener(this);
}
@Override
public void added(Rule rule) {
if (!rule.getTags().contains(RULES_TAG)) {
return;
}
HueRuleEntry entry = new HueRuleEntry(rule.getName());
String desc = rule.getDescription();
if (desc != null) {
entry.description = desc;
}
rule.getConditions().stream().filter(c -> c.getTypeUID().equals("hue.ruleCondition")).forEach(c -> {
HueRuleEntry.Condition condition = c.getConfiguration().as(HueRuleEntry.Condition.class);
// address with pattern "/sensors/2/state/buttonevent"
String[] parts = condition.address.split("/");
if (parts.length < 3) {
return;
}
entry.conditions.add(condition);
});
rule.getActions().stream().filter(a -> a.getTypeUID().equals("rules.HttpAction")).forEach(a -> {
HueCommand command = RuleUtils.httpActionToHueCommand(cs.ds, a, rule.getName());
if (command == null) {
return;
}
// Remove the "/api/{user}" part
String[] parts = command.address.split("/");
command.address = "/" + String.join("/", Arrays.copyOfRange(parts, 3, parts.length));
entry.actions.add(command);
});
cs.ds.rules.put(rule.getUID(), entry);
}
@Override
public void removed(Rule element) {
cs.ds.rules.remove(element.getUID());
}
@Override
public void updated(Rule oldElement, Rule element) {
removed(oldElement);
added(element);
}
protected static Map.Entry<Trigger, Condition> hueConditionToAutomation(String id, HueRuleEntry.Condition condition,
ItemRegistry itemRegistry) {
// pattern: "/sensors/2/state/buttonevent"
String[] parts = condition.address.split("/");
if (parts.length < 3) {
throw new IllegalStateException("Condition address invalid: " + condition.address);
}
final Configuration triggerConfig = new Configuration();
String itemName = parts[2];
Item item = itemRegistry.get(itemName);
if (item == null) {
throw new IllegalStateException("Item of address does not exist: " + itemName);
}
triggerConfig.put("itemName", itemName);
// There might be multiple triggers for the same item. Due to the map, we are only creating one though
Trigger trigger = ModuleBuilder.createTrigger().withId(id).withTypeUID("core.ItemStateChangeTrigger")
.withConfiguration(triggerConfig).build();
// Connect the outputs of the trigger with the inputs of the condition
Map<String, String> inputs = new TreeMap<>();
inputs.put("newState", id);
inputs.put("oldState", id);
// Config for condition
final Configuration conditionConfig = new Configuration();
conditionConfig.put("operator", condition.operator.name());
conditionConfig.put("address", condition.address);
String value = condition.value;
if (value != null) {
conditionConfig.put("value", value);
}
Condition conditon = ModuleBuilder.createCondition().withId(id + "-condition").withTypeUID("hue.ruleCondition")
.withConfiguration(conditionConfig).withInputs(inputs).build();
return new AbstractMap.SimpleEntry<>(trigger, conditon);
}
protected static RuleBuilder createHueRuleConditions(List<HueRuleEntry.Condition> hueConditions,
RuleBuilder builder, List<Trigger> oldTriggers, List<Condition> oldConditions, ItemRegistry itemRegistry) {
// Preserve all triggers, conditions that are not part of hue rules
Map<String, Trigger> triggers = new TreeMap<>();
triggers.putAll(oldTriggers.stream().filter(a -> !a.getTypeUID().equals("core.ItemStateChangeTrigger"))
.collect(Collectors.toMap(e -> e.getId(), e -> e)));
Map<String, Condition> conditions = new TreeMap<>();
conditions.putAll(oldConditions.stream().filter(a -> !a.getTypeUID().equals("hue.ruleCondition"))
.collect(Collectors.toMap(e -> e.getId(), e -> e)));
for (HueRuleEntry.Condition condition : hueConditions) {
String id = condition.address.replace("/", "-");
Entry<Trigger, Condition> entry = hueConditionToAutomation(id, condition, itemRegistry);
triggers.put(id, entry.getKey());
conditions.put(id, entry.getValue());
}
builder.withTriggers(new ArrayList<>(triggers.values())).withConditions(new ArrayList<>(conditions.values()));
return builder;
}
protected static List<Action> createActions(String uid, List<HueCommand> hueActions, List<Action> oldActions,
String apikey) {
// Preserve all actions that are not "rules.HttpAction"
List<Action> actions = new ArrayList<>(oldActions);
actions.removeIf(a -> a.getTypeUID().equals("rules.HttpAction"));
for (HueCommand command : hueActions) {
command.address = "/api/" + apikey + command.address;
actions.add(RuleUtils.createHttpAction(command, command.address.replace("/", "-")));
}
return actions;
}
@GET
@Path("{username}/rules")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return all rules")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getRulesApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.rules)).build();
}
@GET
@Path("{username}/rules/{id}")
@ApiOperation(value = "Return a rule")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getRuleApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "rule id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.rules.get(id))).build();
}
@DELETE
@Path("{username}/rules/{id}")
@ApiOperation(value = "Deletes a rule")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The user got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeRuleApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "Rule to remove") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Rule does not exist!");
}
return NetworkUtils.singleSuccess(cs.gson, "/rules/" + id + " deleted.");
}
@PUT
@Path("{username}/rules/{id}")
@ApiOperation(value = "Set rule attributes")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response modifyRuleApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "rule id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
final HueRuleEntry changeRequest = cs.gson.fromJson(body, HueRuleEntry.class);
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Rule does not exist!");
}
RuleBuilder builder = RuleBuilder.create(rule);
String temp;
temp = changeRequest.name;
if (!temp.isEmpty()) {
builder.withName(changeRequest.name);
}
temp = changeRequest.description;
if (!temp.isEmpty()) {
builder.withDescription(temp);
}
try {
if (!changeRequest.actions.isEmpty()) {
builder.withActions(createActions(rule.getUID(), changeRequest.actions, rule.getActions(), username));
}
if (!changeRequest.conditions.isEmpty()) {
builder = createHueRuleConditions(changeRequest.conditions, builder, rule.getTriggers(),
rule.getConditions(), itemRegistry);
}
ruleRegistry.add(builder.build());
} catch (IllegalStateException e) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
return NetworkUtils.successList(cs.gson, Arrays.asList( //
new HueSuccessGeneric(changeRequest.name, "/rules/" + id + "/name"), //
new HueSuccessGeneric(changeRequest.description, "/rules/" + id + "/description"), //
new HueSuccessGeneric(changeRequest.actions.toString(), "/rules/" + id + "/actions"), //
new HueSuccessGeneric(changeRequest.conditions.toString(), "/rules/" + id + "/conditions") //
));
}
@SuppressWarnings({ "null" })
@POST
@Path("{username}/rules")
@ApiOperation(value = "Create a new rule")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response postNewRule(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueRuleEntry newRuleData = cs.gson.fromJson(body, HueRuleEntry.class);
if (newRuleData == null || newRuleData.name.isEmpty() || newRuleData.actions.isEmpty()
|| newRuleData.conditions.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No name or actions or conditons!");
}
String uid = UUID.randomUUID().toString();
RuleBuilder builder = RuleBuilder.create(uid).withName(newRuleData.name);
String description = newRuleData.description;
if (description != null) {
builder.withDescription(description);
}
try {
builder.withActions(createActions(uid, newRuleData.actions, Collections.emptyList(), username));
builder = createHueRuleConditions(newRuleData.conditions, builder, Collections.emptyList(),
Collections.emptyList(), itemRegistry);
ruleRegistry.add(builder.withTags(RULES_TAG).build());
} catch (IllegalStateException e) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
return NetworkUtils.singleSuccess(cs.gson, uid, "id");
}
}

View File

@@ -0,0 +1,422 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.util.ModuleBuilder;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.StateUtils;
import org.openhab.io.hueemulation.internal.automation.dto.ItemCommandActionConfig;
import org.openhab.io.hueemulation.internal.dto.AbstractHueState;
import org.openhab.io.hueemulation.internal.dto.HueSceneEntry;
import org.openhab.io.hueemulation.internal.dto.HueSceneWithLightstates;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeSceneEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* Handles Hue scenes via the automation subsystem and the corresponding REST interface
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { Scenes.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class Scenes implements RegistryChangeListener<Rule> {
private final Logger logger = LoggerFactory.getLogger(Scenes.class);
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
@Reference
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
/**
* Registers to the {@link RuleRegistry} and enumerates currently existing rules.
*/
@Activate
public void activate() {
ruleRegistry.removeRegistryChangeListener(this);
ruleRegistry.addRegistryChangeListener(this);
for (Rule item : ruleRegistry.getAll()) {
added(item);
}
}
@Deactivate
public void deactivate() {
ruleRegistry.removeRegistryChangeListener(this);
}
@Override
public void added(Rule scene) {
if (!scene.getTags().contains("scene")) {
return;
}
HueSceneEntry entry = new HueSceneEntry(scene.getName());
String desc = scene.getDescription();
if (desc != null) {
entry.description = desc;
}
List<String> items = new ArrayList<>();
for (Action a : scene.getActions()) {
if (!a.getTypeUID().equals("core.ItemCommandAction")) {
continue;
}
ItemCommandActionConfig config = a.getConfiguration().as(ItemCommandActionConfig.class);
Item item;
try {
item = itemRegistry.getItem(config.itemName);
} catch (ItemNotFoundException e) {
logger.warn("Rule {} is referring to a non existing item {}", scene.getName(), config.itemName);
continue;
}
if (scene.getActions().size() == 1 && item instanceof GroupItem) {
entry.type = HueSceneEntry.TypeEnum.GroupScene;
entry.group = cs.mapItemUIDtoHueID(item);
} else {
items.add(cs.mapItemUIDtoHueID(item));
}
}
if (!items.isEmpty()) {
entry.lights = items;
}
cs.ds.scenes.put(scene.getUID(), entry);
}
@Override
public void removed(Rule element) {
cs.ds.scenes.remove(element.getUID());
}
@Override
public void updated(Rule oldElement, Rule element) {
removed(oldElement);
added(element);
}
@GET
@Path("{username}/scenes")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return all scenes")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getScenesApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.scenes)).build();
}
@SuppressWarnings({ "unused", "null" })
@GET
@Path("{username}/scenes/{id}")
@ApiOperation(value = "Return a scene")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getSceneApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "scene id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueSceneEntry sceneEntry = cs.ds.scenes.get(id);
if (sceneEntry == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
}
HueSceneWithLightstates s = new HueSceneWithLightstates(sceneEntry);
for (String itemID : s.lights) {
Item item;
try {
item = itemRegistry.getItem(itemID);
} catch (ItemNotFoundException e) {
logger.warn("Scene {} is referring to a non existing item {}", sceneEntry.name, itemID);
continue;
}
AbstractHueState state = StateUtils.colorStateFromItemState(item.getState(), null);
s.lightstates.put(cs.mapItemUIDtoHueID(item), state);
}
return Response.ok(cs.gson.toJson(s)).build();
}
@DELETE
@Path("{username}/scenes/{id}")
@ApiOperation(value = "Deletes a scene")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The user got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeSceneApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "Scene to remove") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
}
return NetworkUtils.singleSuccess(cs.gson, "/scenes/" + id + " deleted.");
}
protected static Action actionFromState(String itemID, State state) {
final Configuration actionConfig = new Configuration();
actionConfig.put("itemName", itemID);
actionConfig.put("command", StateUtils.commandByItemState(state).toFullString());
return ModuleBuilder.createAction().withId(itemID).withTypeUID("core.ItemCommandAction")
.withConfiguration(actionConfig).build();
}
protected static Action actionFromState(String itemID, Command command) {
final Configuration actionConfig = new Configuration();
actionConfig.put("itemName", itemID);
actionConfig.put("command", command.toFullString());
return ModuleBuilder.createAction().withId(itemID).withTypeUID("core.ItemCommandAction")
.withConfiguration(actionConfig).build();
}
/**
* Either assigns a new name, description, lights to a scene or directly assign
* a new light state for an entry to a scene
*/
@PUT
@Path("{username}/scenes/{id}")
@ApiOperation(value = "Set scene attributes")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response modifySceneApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "scene id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
final HueChangeSceneEntry changeRequest = cs.gson.fromJson(body, HueChangeSceneEntry.class);
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
}
RuleBuilder builder = RuleBuilder.create(rule);
String temp = changeRequest.name;
if (temp != null) {
builder.withName(temp);
}
temp = changeRequest.description;
if (temp != null) {
builder.withDescription(temp);
}
List<String> lights = changeRequest.lights;
if (changeRequest.storelightstate && lights != null) {
@SuppressWarnings("null")
@NonNullByDefault({})
List<Action> actions = lights.stream().map(itemID -> itemRegistry.get(itemID)).filter(Objects::nonNull)
.map(item -> actionFromState(item.getUID(), item.getState())).collect(Collectors.toList());
builder.withActions(actions);
}
Map<String, HueStateChange> lightStates = changeRequest.lightstates;
if (changeRequest.storelightstate && lightStates != null) {
List<Action> actions = new ArrayList<>(rule.getActions());
for (Map.Entry<String, HueStateChange> entry : lightStates.entrySet()) {
// Remove existing action
actions.removeIf(action -> action.getId().equals(entry.getKey()));
// Assign new action
Command command = StateUtils.computeCommandByChangeRequest(entry.getValue());
if (command == null) {
logger.warn("Failed to compute command for {}", body);
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Cannot compute command!");
}
actions.add(actionFromState(entry.getKey(), command));
}
builder.withActions(actions);
}
try {
ruleRegistry.add(builder.build());
} catch (IllegalStateException e) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
return NetworkUtils.successList(cs.gson, Arrays.asList( //
new HueSuccessGeneric(changeRequest.name, "/scenes/" + id + "/name"), //
new HueSuccessGeneric(changeRequest.description, "/scenes/" + id + "/description"), //
new HueSuccessGeneric(changeRequest.lights != null ? String.join(",", changeRequest.lights) : null,
"/scenes/" + id + "/lights") //
));
}
@SuppressWarnings({ "null" })
@POST
@Path("{username}/scenes")
@ApiOperation(value = "Create a new scene")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response postNewScene(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueSceneEntry newScheduleData = cs.gson.fromJson(body, HueSceneEntry.class);
if (newScheduleData == null || newScheduleData.name.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No name or localtime!");
}
String uid = UUID.randomUUID().toString();
RuleBuilder builder = RuleBuilder.create(uid).withName(newScheduleData.name).withTags("scene");
if (!newScheduleData.description.isEmpty()) {
builder.withDescription(newScheduleData.description);
}
List<String> lights = newScheduleData.lights;
if (lights != null) {
List<Action> actions = new ArrayList<>();
for (String itemID : lights) {
Item item = itemRegistry.get(itemID);
if (item == null) {
continue;
}
actions.add(actionFromState(cs.mapItemUIDtoHueID(item), item.getState()));
}
builder.withActions(actions);
}
String groupid = newScheduleData.group;
if (groupid != null) {
Item groupItem = itemRegistry.get(groupid);
if (groupItem == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, "Group does not exist!");
}
List<Action> actions = Collections
.singletonList(actionFromState(cs.mapItemUIDtoHueID(groupItem), groupItem.getState()));
builder.withActions(actions);
}
try {
ruleRegistry.add(builder.build());
} catch (IllegalStateException e) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
return NetworkUtils.singleSuccess(cs.gson, uid, "id");
}
@PUT
@Path("{username}/scenes/{id}/lightstates/{lightid}")
@ApiOperation(value = "Set scene attributes")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response modifySceneLightStateApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "scene id") String id,
@PathParam("lightid") @ApiParam(value = "light id") String lightid, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
final HueStateChange changeRequest = cs.gson.fromJson(body, HueStateChange.class);
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
}
RuleBuilder builder = RuleBuilder.create(rule);
List<Action> actions = new ArrayList<>(rule.getActions());
// Remove existing action
actions.removeIf(action -> action.getId().equals(lightid));
// Assign new action
Command command = StateUtils.computeCommandByChangeRequest(changeRequest);
if (command == null) {
logger.warn("Failed to compute command for {}", body);
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Cannot compute command!");
}
actions.add(actionFromState(lightid, command));
builder.withActions(actions);
try {
ruleRegistry.add(builder.build());
} catch (IllegalStateException e) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
return NetworkUtils.successList(cs.gson, Arrays.asList( //
new HueSuccessGeneric(changeRequest.on, "/scenes/" + id + "/lightstates/" + lightid + "/on"), //
new HueSuccessGeneric(changeRequest.hue, "/scenes/" + id + "/lightstates/" + lightid + "/hue"), //
new HueSuccessGeneric(changeRequest.sat, "/scenes/" + id + "/lightstates/" + lightid + "/sat"), //
new HueSuccessGeneric(changeRequest.bri, "/scenes/" + id + "/lightstates/" + lightid + "/bri"), //
new HueSuccessGeneric(changeRequest.transitiontime,
"/scenes/" + id + "/lightstates/" + lightid + "/transitiontime")));
}
}

View File

@@ -0,0 +1,342 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.automation.Action;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleManager;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.Visibility;
import org.openhab.core.automation.util.ModuleBuilder;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.RuleUtils;
import org.openhab.io.hueemulation.internal.dto.HueDataStore;
import org.openhab.io.hueemulation.internal.dto.HueScheduleEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeScheduleEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* Enables the schedule part of the Hue REST API. Uses automation rules with GenericCronTrigger, TimerTrigger and
* AbsoluteDateTimeTrigger depending on the schedule time pattern.
* <p>
* If the scheduled task should remove itself after completion, a RemoveRuleAction is used in the rule.
* <p>
* The actual command execution uses HttpAction.
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { Schedules.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class Schedules implements RegistryChangeListener<Rule> {
public static final String SCHEDULE_TAG = "hueemulation_schedule";
private final Logger logger = LoggerFactory.getLogger(Schedules.class);
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) RuleManager ruleManager;
@Reference
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
/**
* Registers to the {@link RuleRegistry} and enumerates currently existing rules.
*/
@Activate
public void activate() {
ruleRegistry.removeRegistryChangeListener(this);
ruleRegistry.addRegistryChangeListener(this);
for (Rule item : ruleRegistry.getAll()) {
added(item);
}
}
@Deactivate
public void deactivate() {
ruleRegistry.removeRegistryChangeListener(this);
}
/**
* Called by the registry when a rule got added (and when a rule got modified).
* <p>
* Converts the rule into a {@link HueScheduleEntry} object and add that to the hue datastore.
*/
@Override
public void added(Rule rule) {
if (!rule.getTags().contains(SCHEDULE_TAG)) {
return;
}
HueScheduleEntry entry = new HueScheduleEntry();
entry.name = rule.getName();
entry.description = rule.getDescription();
entry.autodelete = rule.getActions().stream().anyMatch(p -> p.getId().equals("autodelete"));
entry.status = ruleManager.isEnabled(rule.getUID()) ? "enabled" : "disabled";
String timeStringFromTrigger = RuleUtils.timeStringFromTrigger(rule.getTriggers());
if (timeStringFromTrigger == null) {
logger.warn("Schedule from rule '{}' invalid!", rule.getName());
return;
}
entry.localtime = timeStringFromTrigger;
for (Action a : rule.getActions()) {
if (!a.getTypeUID().equals("rules.HttpAction")) {
continue;
}
HueCommand command = RuleUtils.httpActionToHueCommand(cs.ds, a, rule.getName());
if (command == null) {
continue;
}
entry.command = command;
}
cs.ds.schedules.put(rule.getUID(), entry);
}
@Override
public void removed(Rule element) {
cs.ds.schedules.remove(element.getUID());
}
@Override
public void updated(Rule oldElement, Rule element) {
removed(oldElement);
added(element);
}
/**
* Creates a new rule that executes a http rule action, triggered by the scheduled time
*
* @param uid A rule unique id.
* @param builder A rule builder that will be used for creating the rule. It must have been created with the given
* uid.
* @param oldActions Old actions. Useful if `data` is only partially set and old actions should be preserved
* @param data The configuration for the http action and trigger time is in here
* @return A new rule with the given uid
* @throws IllegalStateException If a required parameter is not set or if a light / group that is referred to is not
* existing
*/
protected static Rule createRule(String uid, RuleBuilder builder, List<Action> oldActions,
List<Trigger> oldTriggers, HueChangeScheduleEntry data, HueDataStore ds) throws IllegalStateException {
HueCommand command = data.command;
Boolean autodelete = data.autodelete;
String temp;
temp = data.name;
if (temp != null) {
builder.withName(temp);
} else if (oldActions.isEmpty()) { // This is a new rule without a name yet
throw new IllegalStateException("Name not set!");
}
temp = data.description;
if (temp != null) {
builder.withDescription(temp);
}
temp = data.localtime;
if (temp != null) {
builder.withTriggers(RuleUtils.createTriggerForTimeString(temp));
} else if (oldTriggers.isEmpty()) { // This is a new rule without triggers yet
throw new IllegalStateException("localtime not set!");
}
List<Action> actions = new ArrayList<>(oldActions);
if (command != null) {
RuleUtils.validateHueHttpAddress(ds, command.address);
actions.removeIf(a -> a.getId().equals("command")); // Remove old command action if any and add new one
actions.add(RuleUtils.createHttpAction(command, "command"));
} else if (oldActions.isEmpty()) { // This is a new rule without an action yet
throw new IllegalStateException("No command set!");
}
if (autodelete != null) {
// Remove action to remove rule after execution
actions = actions.stream().filter(e -> !e.getId().equals("autodelete"))
.collect(Collectors.toCollection(() -> new ArrayList<>()));
if (autodelete) { // Add action to remove this rule again after execution
final Configuration actionConfig = new Configuration();
actionConfig.put("removeuid", uid);
actions.add(ModuleBuilder.createAction().withId("autodelete").withTypeUID("rules.RemoveRuleAction")
.withConfiguration(actionConfig).build());
}
}
builder.withActions(actions);
return builder.withVisibility(Visibility.VISIBLE).withTags(SCHEDULE_TAG).build();
}
@GET
@Path("{username}/schedules")
@Produces(MediaType.APPLICATION_JSON)
@ApiOperation(value = "Return all schedules")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getSchedulesApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.schedules)).build();
}
@GET
@Path("{username}/schedules/{id}")
@ApiOperation(value = "Return a schedule")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getScheduleApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "schedule id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.schedules.get(id))).build();
}
@DELETE
@Path("{username}/schedules/{id}")
@ApiOperation(value = "Deletes a schedule")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The user got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeScheduleApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "Schedule to remove") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Schedule does not exist!");
}
return NetworkUtils.singleSuccess(cs.gson, "/schedules/" + id + " deleted.");
}
@PUT
@Path("{username}/schedules/{id}")
@ApiOperation(value = "Set schedule attributes")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response modifyScheduleApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "schedule id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
final HueChangeScheduleEntry changeRequest = cs.gson.fromJson(body, HueChangeScheduleEntry.class);
Rule rule = ruleRegistry.remove(id);
if (rule == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Schedule does not exist!");
}
RuleBuilder builder = RuleBuilder.create(rule);
try {
ruleRegistry.add(
createRule(rule.getUID(), builder, rule.getActions(), rule.getTriggers(), changeRequest, cs.ds));
} catch (IllegalStateException e) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
return NetworkUtils.successList(cs.gson, Arrays.asList( //
new HueSuccessGeneric(changeRequest.name, "/schedules/" + id + "/name"), //
new HueSuccessGeneric(changeRequest.description, "/schedules/" + id + "/description"), //
new HueSuccessGeneric(changeRequest.localtime, "/schedules/" + id + "/localtime"), //
new HueSuccessGeneric(changeRequest.status, "/schedules/" + id + "/status"), //
new HueSuccessGeneric(changeRequest.autodelete, "/schedules/1/autodelete"), //
new HueSuccessGeneric(changeRequest.command, "/schedules/1/command") //
));
}
@SuppressWarnings({ "null" })
@POST
@Path("{username}/schedules")
@ApiOperation(value = "Create a new schedule")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response postNewSchedule(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueScheduleEntry newScheduleData = cs.gson.fromJson(body, HueScheduleEntry.class);
if (newScheduleData == null || newScheduleData.name.isEmpty() || newScheduleData.localtime.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No name or localtime!");
}
String uid = UUID.randomUUID().toString();
RuleBuilder builder = RuleBuilder.create(uid);
Rule rule;
try {
rule = createRule(uid, builder, Collections.emptyList(), Collections.emptyList(), newScheduleData, cs.ds);
} catch (IllegalStateException e) { // No stacktrace required, we just need the exception message
return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
}
ruleRegistry.add(rule);
return NetworkUtils.singleSuccess(cs.gson, uid, "id");
}
}

View File

@@ -0,0 +1,282 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.items.GenericItem;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.dto.HueNewLights;
import org.openhab.io.hueemulation.internal.dto.HueSensorEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeRequest;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* Listens to the ItemRegistry and add all DecimalType, OnOffType, ContactType, DimmerType items
* as sensors.
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { Sensors.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class Sensors implements RegistryChangeListener<Item> {
private final Logger logger = LoggerFactory.getLogger(Sensors.class);
private static final Set<String> ALLOWED_ITEM_TYPES = Stream.of(CoreItemFactory.COLOR, CoreItemFactory.DIMMER,
CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH, CoreItemFactory.CONTACT, CoreItemFactory.NUMBER)
.collect(Collectors.toSet());
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UserManagement userManagement;
@Reference
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
/**
* Registers to the {@link ItemRegistry} and enumerates currently existing items.
* Call {@link #close(ItemRegistry)} when you are done with this object.
*
* Only call this after you have set the filter tags with {@link #setFilterTags(Set, Set, Set)}.
*/
@Activate
protected void activate() {
cs.ds.resetSensors();
itemRegistry.removeRegistryChangeListener(this);
itemRegistry.addRegistryChangeListener(this);
for (Item item : itemRegistry.getItems()) {
added(item);
}
logger.debug("Added as sensor: {}",
cs.ds.sensors.values().stream().map(l -> l.name).collect(Collectors.joining(", ")));
}
/**
* Unregisters from the {@link ItemRegistry}.
*/
@Deactivate
protected void deactivate() {
itemRegistry.removeRegistryChangeListener(this);
}
@Override
public synchronized void added(Item newElement) {
if (!(newElement instanceof GenericItem)) {
return;
}
GenericItem element = (GenericItem) newElement;
if (!ALLOWED_ITEM_TYPES.contains(element.getType())) {
return;
}
String hueID = cs.mapItemUIDtoHueID(element);
HueSensorEntry sensor = new HueSensorEntry(element);
cs.ds.sensors.put(hueID, sensor);
}
@Override
public synchronized void removed(Item element) {
String hueID = cs.mapItemUIDtoHueID(element);
logger.debug("Remove item {}", hueID);
cs.ds.sensors.remove(hueID);
}
@Override
public synchronized void updated(Item oldElement, Item newElement) {
if (!(newElement instanceof GenericItem)) {
return;
}
GenericItem element = (GenericItem) newElement;
String hueID = cs.mapItemUIDtoHueID(element);
HueSensorEntry sensor = new HueSensorEntry(element);
cs.ds.sensors.put(hueID, sensor);
}
@GET
@Path("{username}/sensors")
@ApiOperation(value = "Return all sensors")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getAllSensorsApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.sensors)).build();
}
@GET
@Path("{username}/sensors/new")
@ApiOperation(value = "Return new sensors since last scan. Returns an empty list for openHAB as we do not cache that information.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getNewSensors(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(new HueNewLights())).build();
}
@POST
@Path("{username}/sensors")
@ApiOperation(value = "Starts a new scan for compatible items. This is usually not necessary, because we are observing the item registry.")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response postNewLights(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return NetworkUtils.singleSuccess(cs.gson, "Searching for new sensors", "/sensors");
}
@GET
@Path("{username}/sensors/{id}")
@ApiOperation(value = "Return a sensor")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getSensorApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "sensor id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.sensors.get(id))).build();
}
@SuppressWarnings({ "null", "unused" })
@GET
@Path("{username}/sensors/{id}/config")
@ApiOperation(value = "Return a sensor config. Always empty")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getSensorConfigApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "sensor id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueSensorEntry sensor = cs.ds.sensors.get(id);
if (sensor == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Sensor does not exist");
}
return Response.ok(cs.gson.toJson(sensor.config)).build();
}
@SuppressWarnings({ "null", "unused" })
@DELETE
@Path("{username}/sensors/{id}")
@ApiOperation(value = "Deletes the sensor that is represented by this id")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The item got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeSensorAPI(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "id") String id) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueSensorEntry sensor = cs.ds.sensors.get(id);
if (sensor == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Sensor does not exist");
}
if (itemRegistry.remove(id) != null) {
return NetworkUtils.singleSuccess(cs.gson, "/sensors/" + id + " deleted.");
} else {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Sensor does not exist");
}
}
@SuppressWarnings({ "null", "unused" })
@PUT
@Path("{username}/sensors/{id}")
@ApiOperation(value = "Rename a sensor")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response renameLightApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "light id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
HueSensorEntry sensor = cs.ds.sensors.get(id);
if (sensor == null) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Sensor not existing");
}
final HueChangeRequest changeRequest = cs.gson.fromJson(body, HueChangeRequest.class);
String name = changeRequest.name;
if (name == null || name.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON, "Invalid request: No name set");
}
sensor.item.setLabel(name);
itemRegistry.update(sensor.item);
return NetworkUtils.singleSuccess(cs.gson, name, "/sensors/" + id + "/name");
}
@PUT
@Path("{username}/sensors/{id}/state")
@ApiOperation(value = "Set sensor state")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response setSensorStateApi(@Context UriInfo uri, //
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "sensor id") String id, String body) {
if (!userManagement.authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return NetworkUtils.singleError(cs.gson, uri, HueResponse.SENSOR_NOT_CLIP_SENSOR,
"Invalid request: Not a clip sensor");
}
}

View File

@@ -0,0 +1,263 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.net.URI;
import java.util.stream.Collectors;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.UpnpService;
import org.jupnp.controlpoint.ControlPoint;
import org.jupnp.model.meta.Device;
import org.jupnp.model.meta.DeviceDetails;
import org.jupnp.model.meta.LocalDevice;
import org.jupnp.model.meta.RemoteDevice;
import org.jupnp.registry.Registry;
import org.jupnp.registry.RegistryListener;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.upnp.UpnpServer;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicyOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class is used by the status REST API for troubleshoot purposes.
* <p>
* The UPNP announcement is tested, the /description.xml reachability is checked,
* and some statistics are gathered.
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { StatusResource.class }, property = "com.eclipsesource.jaxrs.publish=false")
@Path("")
@NonNullByDefault
public class StatusResource implements RegistryListener {
@Reference(cardinality = ReferenceCardinality.OPTIONAL, policyOption = ReferencePolicyOption.GREEDY)
protected @Nullable UpnpServer discovery;
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) UpnpService upnpService;
private enum upnpStatus {
service_not_registered,
service_registered_but_no_UPnP_traffic_yet,
upnp_announcement_thread_not_running,
any_device_but_not_this_one_found,
success
}
private upnpStatus selfTestUpnpFound = upnpStatus.service_not_registered;
private final Logger logger = LoggerFactory.getLogger(StatusResource.class);
/**
* This will register to the {@link UpnpService} registry to get notified of new UPNP devices and check all existing
* devices if they match our UPNP announcement.
*/
public void startUpnpSelfTest() {
Registry registry = upnpService.getRegistry();
if (registry == null) {
logger.warn("upnp service registry is null!");
return;
}
selfTestUpnpFound = upnpStatus.service_registered_but_no_UPnP_traffic_yet;
for (RemoteDevice device : registry.getRemoteDevices()) {
remoteDeviceAdded(registry, device);
}
for (LocalDevice device : registry.getLocalDevices()) {
localDeviceAdded(registry, device);
}
registry.addListener(this);
ControlPoint controlPoint = upnpService.getControlPoint();
if (controlPoint != null) {
controlPoint.search();
}
}
@GET
@Path("status/link")
@Produces("text/plain")
public Response link(@Context UriInfo uri) {
cs.setLinkbutton(!cs.ds.config.linkbutton, true, uri.getQueryParameters().containsKey("v1"));
URI newuri = uri.getBaseUri().resolve("/api/status");
return Response.seeOther(newuri).build();
}
private static String toYesNo(boolean b) {
return b ? "yes" : "no";
}
private static String TD(String s) {
return "<td>" + s + "</td>";
}
private static String TR(String s) {
return "<tr>" + s + "</tr>";
}
@GET
@Path("status")
@Produces("text/html")
public String getStatus() {
UpnpServer localDiscovery = discovery;
if (localDiscovery == null) { // Optional service wiring
return "UPnP Server service not started!";
}
String format = "<html><body><h1>Self test</h1>" + //
"<p>To access any links you need be in pairing mode!</p>" + //
"<p>Pairing mode: %s (%s) <a href='%s/api/status/link'>Enable</a> | <a href='%s/api/status/link?V1=true'>Enable with bridge V1 emulation</a></p>"
+ //
"%d published lights (see <a href='%s/api/testuser/lights'>%s/api/testuser/lights</a>)<br>" + //
"%d published sensors (see <a href='%s/api/testuser/sensors'>%s/api/testuser/sensors</a>)<br>" + //
"<h2>UPnP discovery test</h2>" + //
"<p>%s</p>" + //
"<table style='border:1px solid black'><tr><td>serial no</td><td>name</td></tr>%s</table>" + //
"<h2>Reachability test</h2>" + //
"<table style='border:1px solid black'><tr><td>URL</td><td>Responds?</td><td>Ours?</td></tr>%s</table>"
+ //
"<h2>Users</h2><ul>%s</ul></body></html>";
String users = cs.ds.config.whitelist.entrySet().stream().map(user -> "<li>" + user.getKey() + " <b>"
+ user.getValue().name + "</b> <small>" + user.getValue().lastUseDate + "</small>")
.collect(Collectors.joining("\n"));
String url = "http://" + cs.ds.config.ipaddress + ":" + String.valueOf(localDiscovery.getDefaultport());
String reachable = localDiscovery.selfTests().stream()
.map(entry -> TR(TD(entry.address) + TD(toYesNo(entry.reachable)) + TD(toYesNo(entry.isOurs))))
.collect(Collectors.joining("\n"));
Registry registry = upnpService.getRegistry();
String upnps;
if (registry != null) {
upnps = registry.getRemoteDevices().stream().map(device -> getDetails(device))
.map(details -> TR(TD(details.getSerialNumber()) + TD(details.getFriendlyName())))
.collect(Collectors.joining("\n"));
} else {
upnps = TR(TD("service not available") + TD(""));
}
if (!localDiscovery.upnpAnnouncementThreadRunning()) {
selfTestUpnpFound = upnpStatus.upnp_announcement_thread_not_running;
}
return String.format(format, cs.ds.config.linkbutton ? "On" : "Off",
cs.getConfig().temporarilyEmulateV1bridge ? "V1" : "V2", url, url, //
cs.ds.lights.size(), url, url, cs.ds.sensors.size(), url, url, //
selfTestUpnpFound.name().replace('_', ' '), //
upnps, reachable, users);
}
@NonNullByDefault({})
@Override
public void remoteDeviceDiscoveryStarted(Registry registry, RemoteDevice device) {
}
@NonNullByDefault({})
@Override
public void remoteDeviceDiscoveryFailed(Registry registry, RemoteDevice device, Exception ex) {
}
@NonNullByDefault({})
@Override
public void remoteDeviceAdded(Registry registry, RemoteDevice device) {
if (selfTestUpnpFound == upnpStatus.success) {
return;
}
checkForDevice(getDetails(device));
}
private static DeviceDetails getDetails(@Nullable Device<?, ?, ?> device) {
if (device != null) {
DeviceDetails details = device.getDetails();
if (details != null) {
return details;
}
}
return new DeviceDetails(null, "", null, null, "", null, null, null, null, null);
}
private void checkForDevice(DeviceDetails details) {
selfTestUpnpFound = upnpStatus.any_device_but_not_this_one_found;
try {
if (cs.ds.config.bridgeid.equals(details.getSerialNumber())) {
selfTestUpnpFound = upnpStatus.success;
}
} catch (Exception e) { // We really don't want the service to fail on any exception
logger.warn("upnp service: adding services failed: {}", details.getFriendlyName(), e);
}
}
@NonNullByDefault({})
@Override
public void remoteDeviceUpdated(Registry registry, RemoteDevice device) {
}
@NonNullByDefault({})
@Override
public void remoteDeviceRemoved(Registry registry, RemoteDevice device) {
UpnpServer localDiscovery = discovery;
if (selfTestUpnpFound != upnpStatus.success || localDiscovery == null
|| localDiscovery.upnpAnnouncementThreadRunning()) {
return;
}
DeviceDetails details = getDetails(device);
String serialNo = details.getSerialNumber();
if (cs.ds.config.bridgeid.equals(serialNo)) {
selfTestUpnpFound = upnpStatus.any_device_but_not_this_one_found;
}
}
@NonNullByDefault({})
@Override
public void localDeviceAdded(Registry registry, LocalDevice device) {
UpnpServer localDiscovery = discovery;
if (selfTestUpnpFound == upnpStatus.success || localDiscovery == null
|| localDiscovery.upnpAnnouncementThreadRunning()) {
return;
}
checkForDevice(getDetails(device));
}
@NonNullByDefault({})
@Override
public void localDeviceRemoved(Registry registry, LocalDevice device) {
}
@NonNullByDefault({})
@Override
public void beforeShutdown(Registry registry) {
}
@Override
public void afterShutdown() {
selfTestUpnpFound = upnpStatus.service_not_registered;
}
}

View File

@@ -0,0 +1,221 @@
/**
* Copyright (c) 2010-2020 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.rest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.common.registry.DefaultAbstractManagedProvider;
import org.openhab.core.storage.StorageService;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.NetworkUtils;
import org.openhab.io.hueemulation.internal.dto.HueUserAuth;
import org.openhab.io.hueemulation.internal.dto.HueUserAuthWithSecrets;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCreateUser;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseCreateUser;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.reflect.TypeToken;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
/**
* Manages users of this emulated HUE bridge. Stores users in the frameworks storage backend.
* <p>
* This is an OSGi component. Usage:
*
* <pre>
* &#64;Reference
* UserManagement userManagment;
* </pre>
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = false, service = { UserManagement.class }, property = "com.eclipsesource.jaxrs.publish=false")
@NonNullByDefault
@Path("")
@Produces(MediaType.APPLICATION_JSON)
public class UserManagement extends DefaultAbstractManagedProvider<HueUserAuthWithSecrets, String> {
private final Logger logger = LoggerFactory.getLogger(UserManagement.class);
protected final ConfigStore cs;
@Activate
public UserManagement(final @Reference StorageService storageService, final @Reference ConfigStore cs) {
super(storageService);
this.cs = cs;
for (HueUserAuthWithSecrets userAuth : getAll()) {
cs.ds.config.whitelist.put(userAuth.getUID(), userAuth);
}
}
/**
* Checks if the username exists in the whitelist
*/
@SuppressWarnings("null")
public boolean authorizeUser(String userName) {
HueUserAuth userAuth = cs.ds.config.whitelist.get(userName);
if (cs.ds.config.linkbutton && cs.ds.config.createNewUserOnEveryEndpoint) {
addUser(userName, userName, "On-the-go-user");
userAuth = cs.ds.config.whitelist.get(userName);
}
if (userAuth != null) {
userAuth.lastUseDate = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
update((HueUserAuthWithSecrets) userAuth);
}
return userAuth != null;
}
/**
* Adds a user to the whitelist and persist the user file
*
* @param apiKey The hue "username" which is actually an API key
* @param clientKey The UDP/DTLS client key
* @param The user visible name
*/
private void addUser(String apiKey, String clientKey, String label) {
if (cs.ds.config.whitelist.containsKey(apiKey)) {
return;
}
logger.debug("APIKey {} added", apiKey);
String l[] = label.split("#");
HueUserAuthWithSecrets hueUserAuth = new HueUserAuthWithSecrets(l[0], l.length == 2 ? l[1] : "openhab", apiKey,
clientKey);
cs.ds.config.whitelist.put(apiKey, hueUserAuth);
add(hueUserAuth);
}
@SuppressWarnings("null")
private synchronized void removeUser(String apiKey) {
HueUserAuth userAuth = cs.ds.config.whitelist.remove(apiKey);
if (userAuth != null) {
logger.debug("APIKey {} removed", apiKey);
}
remove(apiKey);
}
@Override
protected String getStorageName() {
return "hueEmulationUsers";
}
@Override
protected String keyToString(String key) {
return key;
}
@GET
public Response illegalGetUserAccessApi(@Context UriInfo uri) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.METHOD_NOT_ALLOWED, "Not Authorized");
}
@POST
@ApiOperation(value = "Create an API Key")
@ApiResponses(value = { @ApiResponse(code = 200, message = "API Key created"),
@ApiResponse(code = 403, message = "Link button not pressed") })
public Response createNewUser(@Context UriInfo uri, String body) {
if (!cs.ds.config.linkbutton) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.LINK_BUTTON_NOT_PRESSED,
"link button not pressed");
}
final HueCreateUser userRequest;
userRequest = cs.gson.fromJson(body, HueCreateUser.class);
if (userRequest.devicetype.isEmpty()) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
"Invalid request: No devicetype set");
}
String apiKey = UUID.randomUUID().toString();
String clientKey = UUID.randomUUID().toString();
addUser(apiKey, clientKey, userRequest.devicetype);
HueSuccessResponseCreateUser h = new HueSuccessResponseCreateUser(apiKey, clientKey);
String result = cs.gson.toJson(Collections.singleton(new HueResponse(h)), new TypeToken<List<?>>() {
}.getType());
return Response.ok(result).build();
}
@GET
@Path("{username}/config/whitelist/{userid}")
@ApiOperation(value = "Return a user")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getUserApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("userid") @ApiParam(value = "User ID") String userid) {
if (!authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.config.whitelist.get(userid))).build();
}
@GET
@Path("{username}/config/whitelist")
@ApiOperation(value = "Return all users")
@ApiResponses(value = { @ApiResponse(code = 200, message = "OK") })
public Response getAllUsersApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username) {
if (!authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
return Response.ok(cs.gson.toJson(cs.ds.config.whitelist)).build();
}
@DELETE
@Path("{username}/config/whitelist/{id}")
@ApiOperation(value = "Deletes a user")
@ApiResponses(value = { @ApiResponse(code = 200, message = "The user got removed"),
@ApiResponse(code = 403, message = "Access denied") })
public Response removeUserApi(@Context UriInfo uri,
@PathParam("username") @ApiParam(value = "username") String username,
@PathParam("id") @ApiParam(value = "User to remove") String id) {
if (!authorizeUser(username)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
}
if (!username.equals(id)) {
return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED,
"You can only remove yourself not someone else!");
}
removeUser(username);
return NetworkUtils.singleSuccess(cs.gson, "/config/whitelist/" + username + " deleted.");
}
}

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) 2010-2020 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.upnp;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.channels.Selector;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.io.hueemulation.internal.HueEmulationConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The upnp server runtime configuration. Based on a {@link HueEmulationConfig} and determined ip address and port.
* This extends {@link Thread}, because a runtime configuration is always valid for exactly one thread to be started
* once.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
class HueEmulationConfigWithRuntime extends Thread implements Runnable {
private final Logger logger = LoggerFactory.getLogger(HueEmulationConfigWithRuntime.class);
final @NonNullByDefault({}) HueEmulationConfig config;
final InetAddress address;
final String addressString;
final InetAddress multicastAddress;
int port;
final CompletableFuture<@Nullable HueEmulationConfigWithRuntime> future = new CompletableFuture<>();
final Consumer<HueEmulationConfigWithRuntime> r;
// IO
public @Nullable Selector asyncIOselector;
private boolean hasAlreadyBeenStarted = false;
HueEmulationConfigWithRuntime(Consumer<HueEmulationConfigWithRuntime> r, HueEmulationConfig config,
String addrString, InetAddress MULTI_ADDR_IPV4, InetAddress MULTI_ADDR_IPV6) throws UnknownHostException {
super("HueEmulation UPNP Server");
this.r = r;
this.config = config;
address = InetAddress.getByName(addrString);
if (address instanceof Inet6Address) {
addressString = "[" + address.getHostAddress().split("%")[0] + "]";
multicastAddress = MULTI_ADDR_IPV6;
} else {
addressString = address.getHostAddress();
multicastAddress = MULTI_ADDR_IPV4;
}
port = config.discoveryHttpPort == 0 ? Integer.getInteger("org.osgi.service.http.port", 8080)
: config.discoveryHttpPort;
}
HueEmulationConfigWithRuntime(Consumer<HueEmulationConfigWithRuntime> r, @Nullable HueEmulationConfig config,
InetAddress MULTI_ADDR_IPV4, InetAddress MULTI_ADDR_IPV6) throws UnknownHostException {
super("HueEmulation UPNP Server");
this.r = r;
this.config = config;
address = InetAddress.getByName("localhost");
if (address instanceof Inet6Address) {
addressString = "[" + address.getHostAddress().split("%")[0] + "]";
multicastAddress = MULTI_ADDR_IPV6;
} else {
addressString = address.getHostAddress();
multicastAddress = MULTI_ADDR_IPV4;
}
port = 8080;
}
String getMulticastAddress() {
if (multicastAddress instanceof Inet6Address) {
return "[" + multicastAddress.getHostAddress().split("%")[0] + "]";
} else {
return multicastAddress.getHostAddress();
}
}
public synchronized CompletableFuture<@Nullable HueEmulationConfigWithRuntime> startNow(
@Nullable HueEmulationConfigWithRuntime ignored) {
if (hasAlreadyBeenStarted) {
logger.debug("Cannot restart thread");
return future;
}
hasAlreadyBeenStarted = true;
super.start();
return future;
}
@Override
public void run() {
r.accept(this);
}
public void dispose() {
Selector selector = asyncIOselector;
if (selector != null) {
try {
selector.close();
} catch (IOException ignored) {
}
try {
join();
} catch (InterruptedException ignore) {
Thread.currentThread().interrupt();
}
asyncIOselector = null;
}
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2010-2020 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.upnp;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* A self test report includes the tested address, a success flag and
* if the service at the given address is actually ours.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class SelfTestReport {
public final String address;
public final boolean reachable;
public final boolean isOurs;
SelfTestReport(String address, boolean testReport, boolean isOurs) {
this.address = address;
this.reachable = testReport;
this.isOurs = isOurs;
}
/**
* A failed address is not reachable and not ours
*/
public static SelfTestReport failed(String address) {
return new SelfTestReport(address, false, false);
}
}

View File

@@ -0,0 +1,536 @@
/**
* Copyright (c) 2010-2020 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.upnp;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.SocketAddress;
import java.net.StandardProtocolFamily;
import java.net.StandardSocketOptions;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedSelectorException;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.ProcessingException;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.ClientProperties;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;
import org.osgi.service.http.HttpService;
import org.osgi.service.http.NamespaceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Advertises a Hue compatible bridge via UPNP and provides the announced /description.xml http endpoint.
*
* @author Dan Cunningham - Initial contribution
* @author David Graeff - Rewritten
*/
@SuppressWarnings("serial")
@NonNullByDefault
@Component(immediate = false, // Don't start the upnp server on its own. Must be pulled in by HueEmulationService.
configurationPolicy = ConfigurationPolicy.IGNORE, property = {
EventConstants.EVENT_TOPIC + "=" + ConfigStore.EVENT_ADDRESS_CHANGED,
"com.eclipsesource.jaxrs.publish=false" }, //
service = { UpnpServer.class, EventHandler.class })
public class UpnpServer extends HttpServlet implements Consumer<HueEmulationConfigWithRuntime>, EventHandler {
/**
* Used by async IO. This is our context object class.
*/
static class ClientRecord {
public @Nullable SocketAddress clientAddress;
public ByteBuffer buffer = ByteBuffer.allocate(1000);
}
public static final String DISCOVERY_FILE = "/description.xml";
// jUPNP shares port 1900, but since this is multicast, we can also bind to it
public static final int UPNP_PORT = 1900;
/**
* Send a keep alive every 2 minutes
*/
private static final int CACHE_MSECS = 120 * 1000;
private final Logger logger = LoggerFactory.getLogger(UpnpServer.class);
public final InetAddress MULTI_ADDR_IPV4;
public final InetAddress MULTI_ADDR_IPV6;
private String[] stVersions = { "", "", "" };
private String notifyMsg = "";
//// objects, set within activate()
protected @NonNullByDefault({}) String xmlDoc;
protected @NonNullByDefault({}) String xmlDocWithAddress;
private @NonNullByDefault({}) String baseurl;
//// services
@Reference
protected @NonNullByDefault({}) ConfigStore cs;
@Reference
protected @NonNullByDefault({}) HttpService httpService;
public boolean overwriteReadyToFalse = false;
private HueEmulationConfigWithRuntime config;
protected CompletableFuture<@Nullable HueEmulationConfigWithRuntime> configChangeFuture = CompletableFuture
.completedFuture(config);
private List<SelfTestReport> selfTests = new ArrayList<>();
private final Executor executor;
/**
* Creates a server instance.
* UPnP IPv4/v6 multicast addresses are determined.
*/
public UpnpServer() {
this(ForkJoinPool.commonPool());
}
public UpnpServer(Executor executor) {
try {
MULTI_ADDR_IPV4 = InetAddress.getByName("239.255.255.250");
MULTI_ADDR_IPV6 = InetAddress.getByName("ff02::c");
config = new HueEmulationConfigWithRuntime(this, null, MULTI_ADDR_IPV4, MULTI_ADDR_IPV6);
} catch (UnknownHostException e) {
throw new IllegalStateException(e);
}
this.executor = executor;
}
/**
* this object is also a servlet for providing /description.xml, the UPnP discovery result
*/
@NonNullByDefault({})
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
if (xmlDocWithAddress == null || xmlDocWithAddress.isEmpty()) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
try (PrintWriter out = resp.getWriter()) {
resp.setContentType("application/xml");
out.write(xmlDocWithAddress);
}
}
/**
* Server to send UDP packets onto the network when requested by a Hue API compatible device.
*
* @param relativePath The URI path where the discovery xml document can be retrieved
* @param config The hue datastore. Contains the bridgeid and uuid.
* @param address IP to advertise for UPNP
* @throws IOException
* @throws NamespaceException
* @throws ServletException
*/
@Activate
protected void activate() {
InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("discovery.xml");
if (resourceAsStream == null) {
logger.warn("Could not start Hue Emulation service: discovery.xml not found");
return;
}
try (InputStreamReader r = new InputStreamReader(resourceAsStream, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(r)) {
xmlDoc = br.lines().collect(Collectors.joining("\n"));
} catch (IOException e) {
logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
return;
}
try {
httpService.unregister(DISCOVERY_FILE);
} catch (IllegalArgumentException ignore) {
}
try {
httpService.registerServlet(DISCOVERY_FILE, this, null, null);
} catch (ServletException | NamespaceException e) {
logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
}
if (cs.isReady() && !overwriteReadyToFalse) {
handleEvent(null);
}
}
private void useAddressPort(HueEmulationConfigWithRuntime r) {
final String urlBase = "http://" + r.addressString + ":" + r.port;
this.baseurl = urlBase + DISCOVERY_FILE;
final String[] stVersions = { "upnp:rootdevice", "urn:schemas-upnp-org:device:basic:1",
"uuid:" + config.config.uuid };
for (int i = 0; i < stVersions.length; ++i) {
this.stVersions[i] = String.format(
"HTTP/1.1 200 OK\r\n" + "HOST: %s:%d\r\n" + "EXT:\r\n" + "CACHE-CONTROL: max-age=%d\r\n"
+ "LOCATION: %s\r\n" + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\n"
+ "hue-bridgeid: %s\r\n" + "ST: %s\r\n" + "USN: uuid:%s\r\n\r\n",
r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port,
// cache,location
cs.ds.config.apiversion, cs.ds.config.bridgeid, // version, bridgeid
stVersions[i], config.config.uuid);
}
this.notifyMsg = String.format(
"NOTIFY * HTTP/1.1\r\n" + "HOST: %s:%d\r\n" + "CACHE-CONTROL: max-age=%d\r\n" + "LOCATION: %s\r\n"
+ "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\nNTS: ssdp:alive\r\nNT: upnp:rootdevice\r\n"
+ "USN: uuid:%s::upnp:rootdevice\r\n" + "hue-bridgeid: %s\r\n\r\n",
r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port, cache,location
cs.ds.config.apiversion, config.config.uuid, cs.ds.config.bridgeid);// version, uuid, bridgeid
xmlDocWithAddress = String.format(xmlDoc, urlBase, r.addressString, cs.ds.config.bridgeid, cs.ds.config.uuid,
cs.ds.config.devicename);
}
protected @Nullable HueEmulationConfigWithRuntime performAddressTest(
@Nullable HueEmulationConfigWithRuntime config) {
if (config == null) {
return null; // Config hasn't changed
}
selfTests.clear();
ClientConfig configuration = new ClientConfig();
configuration = configuration.property(ClientProperties.CONNECT_TIMEOUT, 1000);
configuration = configuration.property(ClientProperties.READ_TIMEOUT, 1000);
Client client = ClientBuilder.newClient(configuration);
Response response;
String url = "";
try {
boolean selfTestOnPort80;
try {
response = client.target("http://" + cs.ds.config.ipaddress + ":80" + DISCOVERY_FILE).request().get();
selfTestOnPort80 = response.getStatus() == 200
&& response.readEntity(String.class).contains(cs.ds.config.bridgeid);
} catch (ProcessingException ignored) {
selfTestOnPort80 = false;
}
// Prefer port 80 if possible and if not overwritten by discoveryHttpPort
config.port = config.config.discoveryHttpPort == 0 && selfTestOnPort80 ? 80 : config.port;
// Test on all assigned interface addresses on org.osgi.service.http.port as well as port 80
// Other services might run on port 80, so we search for our bridge ID in the returned document.
for (InetAddress address : cs.getDiscoveryIps()) {
String ip = address.getHostAddress();
if (address instanceof Inet6Address) {
ip = "[" + ip.split("%")[0] + "]";
}
try {
url = "http://" + ip + ":" + config.port + DISCOVERY_FILE;
response = client.target(url).request().get();
boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
} catch (ProcessingException e) {
logger.debug("Self test fail on {}: {}", url, e.getMessage());
selfTests.add(SelfTestReport.failed(url));
}
try {
url = "http://" + ip + DISCOVERY_FILE; // Port 80
response = client.target(url).request().get();
boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
} catch (ProcessingException e) {
logger.debug("Self test fail on {}: {}", url, e.getMessage());
selfTests.add(SelfTestReport.failed(url));
}
}
} finally {
client.close();
}
return config;
}
/**
* Create and return new runtime configuration based on {@link ConfigStore}s current configuration.
* Return null if the configuration has not changed compared to {@link #config}.
*
* @throws IllegalStateException If the {@link ConfigStore}s IP is invalid this exception is thrown.
*/
protected @Nullable HueEmulationConfigWithRuntime createConfiguration(
@Nullable HueEmulationConfigWithRuntime ignoredParameter) throws IllegalStateException {
HueEmulationConfigWithRuntime r;
try {
r = new HueEmulationConfigWithRuntime(this, cs.getConfig(), cs.ds.config.ipaddress, MULTI_ADDR_IPV4,
MULTI_ADDR_IPV6);
} catch (UnknownHostException e) {
logger.warn("The picked default IP address is not valid: {}", e.getMessage());
throw new IllegalStateException(e);
}
return r;
}
/**
* Apply the given runtime configuration by stopping the current udp thread, shutting down the socket and restarting
* the thread.
*/
protected @Nullable HueEmulationConfigWithRuntime applyConfiguration(
@Nullable HueEmulationConfigWithRuntime newRuntimeConfig) {
if (newRuntimeConfig == null) {
return null;// Config hasn't changed
}
config.dispose();
this.config = newRuntimeConfig;
useAddressPort(config);
return config;
}
/**
* We have a hard dependency on the {@link ConfigStore} and that it has initialized the Hue DataStore config
* completely. That initialization happens asynchronously and therefore we cannot rely on OSGi activate/modified
* state changes. Instead the {@link EventAdmin} is used and we listen for the
* {@link ConfigStore#EVENT_ADDRESS_CHANGED} event that is fired as soon as the config is ready.
* <p>
* To be really sure that we are called here, this is also issued by the main service after it has received the
* configuration ready event and depending on service start order we are also called by our own activate() method
* when the configuration is already ready at that time.
* <p>
* Therefore this method is "synchronized" and chains a completable future for each call to re-evaluate the config
* after the former future has finished.
*/
@Override
public synchronized void handleEvent(@Nullable Event event) {
CompletableFuture<@Nullable HueEmulationConfigWithRuntime> root;
// There is either already a running future, then chain a new one
if (!configChangeFuture.isDone()) {
root = configChangeFuture;
} else { // Or there is none -> create a new one
root = CompletableFuture.completedFuture(null);
}
configChangeFuture = root.thenApply(this::createConfiguration)
.thenApplyAsync(this::performAddressTest, executor).thenApply(this::applyConfiguration)
.thenCompose(config::startNow)
.whenComplete((HueEmulationConfigWithRuntime config, @Nullable Throwable e) -> {
if (e != null) {
logger.warn("Upnp server: Address test failed", e);
}
});
}
/**
* Stops the upnp server from running
*/
@Deactivate
public void deactivate() {
config.dispose();
try {
httpService.unregister(DISCOVERY_FILE);
} catch (IllegalArgumentException ignore) {
}
}
private void handleRead(SelectionKey key) throws IOException {
logger.trace("upnp thread handle received message");
DatagramChannel channel = (DatagramChannel) key.channel();
ClientRecord clntRec = (ClientRecord) key.attachment();
clntRec.buffer.clear(); // Prepare buffer for receiving
clntRec.clientAddress = channel.receive(clntRec.buffer);
InetSocketAddress recAddress = (InetSocketAddress) clntRec.clientAddress;
if (recAddress == null) { // Did we receive something?
return;
}
String data = new String(clntRec.buffer.array(), StandardCharsets.UTF_8);
if (!data.startsWith("M-SEARCH")) {
return;
}
try (DatagramSocket sendSocket = new DatagramSocket()) {
sendUPNPDatagrams(sendSocket, recAddress.getAddress(), recAddress.getPort());
}
}
private void sendUPNPDatagrams(DatagramSocket sendSocket, InetAddress address, int port) {
logger.trace("upnp thread send announcement");
for (String msg : stVersions) {
DatagramPacket response = new DatagramPacket(msg.getBytes(), msg.length(), address, port);
try {
logger.trace("Sending to {}:{}", address.getHostAddress(), port);
sendSocket.send(response);
} catch (IOException e) {
logger.warn("Could not send UPNP response: {}", e.getMessage());
}
}
}
private void sendUPNPNotify(DatagramSocket sendSocket, InetAddress address, int port) {
DatagramPacket response = new DatagramPacket(notifyMsg.getBytes(), notifyMsg.length(), address, port);
try {
logger.trace("Sending to {}:{}", address.getHostAddress(), port);
sendSocket.send(response);
} catch (IOException e) {
logger.warn("Could not send UPNP response: {}", e.getMessage());
}
}
@Override
public void accept(HueEmulationConfigWithRuntime threadContext) {
logger.info("Hue Emulation UPNP server started on {}:{}", threadContext.addressString, threadContext.port);
boolean hasIPv4 = false;
boolean hasIPv6 = false;
try (Selector selector = Selector.open();
DatagramChannel channelV4 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET);
DatagramChannel channelV6 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET6)) {
// Set global config to thread local config. Otherwise upnpAnnouncementThreadRunning() will report wrong
// results.
config = threadContext;
threadContext.asyncIOselector = selector;
for (InetAddress address : cs.getDiscoveryIps()) {
NetworkInterface networkInterface = NetworkInterface.getByInetAddress(address);
if (networkInterface == null) {
continue;
}
if (address instanceof Inet4Address && channelV4 != null) {
channelV4.join(MULTI_ADDR_IPV4, networkInterface);
hasIPv4 = true;
} else if (channelV6 != null) {
channelV6.join(MULTI_ADDR_IPV6, networkInterface);
hasIPv6 = true;
}
}
if (!hasIPv4 && !hasIPv6) {
logger.warn("Could not join upnp multicast network!");
threadContext.future
.completeExceptionally(new IllegalStateException("Could not join upnp multicast network!"));
return;
}
if (hasIPv4) {
channelV4.configureBlocking(false);
channelV4.register(selector, SelectionKey.OP_READ, new ClientRecord());
try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
}
}
if (hasIPv6) {
channelV6.configureBlocking(false);
channelV6.register(selector, SelectionKey.OP_READ, new ClientRecord());
try (DatagramSocket sendSocket = new DatagramSocket()) {
sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
}
}
threadContext.future.complete(threadContext);
Instant time = Instant.now();
while (selector.isOpen()) { // Run forever, receiving and echoing datagrams
// Wait for task or until timeout expires
selector.select(CACHE_MSECS);
Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();
while (keyIter.hasNext()) {
SelectionKey key = keyIter.next();
if (key.isReadable()) {
handleRead(key);
}
keyIter.remove();
}
if (time.plusMillis(CACHE_MSECS - 200).isBefore(Instant.now())) {
logger.trace("upnp thread send periodic announcement");
time = Instant.now();
if (hasIPv4) {
try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
sendUPNPNotify(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
}
}
if (hasIPv6) {
try (DatagramSocket sendSocket = new DatagramSocket()) {
sendUPNPNotify(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
}
}
}
}
} catch (ClosedSelectorException ignored) {
} catch (IOException e) {
logger.warn("Socket error with UPNP server", e);
threadContext.future.completeExceptionally(e);
} finally {
threadContext.asyncIOselector = null;
}
}
@Nullable
private DatagramChannel createBoundDataGramChannelOrNull(StandardProtocolFamily family) throws IOException {
try {
return DatagramChannel.open(family).setOption(StandardSocketOptions.SO_REUSEADDR, true)
.setOption(StandardSocketOptions.IP_MULTICAST_LOOP, true).bind(new InetSocketAddress(UPNP_PORT));
} catch (UnsupportedOperationException uoe) {
return null;
}
}
/**
* The upnp server performs some self-tests
*
* @return
*/
public List<SelfTestReport> selfTests() {
return selfTests;
}
public String getBaseURL() {
return baseurl;
}
public int getDefaultport() {
return config.port;
}
public boolean upnpAnnouncementThreadRunning() {
return config.asyncIOselector != null;
}
}

View File

@@ -0,0 +1,36 @@
{
"triggers": [
{
"uid": "timer.AbsoluteDateTimeTrigger",
"label": "Absolute date/time Trigger",
"description": "This triggers a rule based on a fixed date/time",
"visibility": "VISIBLE",
"configDescriptions": [
{
"name": "date",
"type": "TEXT",
"context": "date",
"label": "Date",
"description": "A date with the pattern yyyy-mm-dd",
"required": false
},
{
"name": "time",
"type": "TEXT",
"context": "time",
"label": "Time",
"description": "A time with the pattern hh:mm:ss",
"required": false
},
{
"name": "randomizeTime",
"type": "TEXT",
"context": "time",
"label": "Randomized Time bound",
"description": "An upper time bound with the pattern hh:mm:ss. If this is given, the trigger triggers on a random time between Time and Randomized Time",
"required": false
}
]
}
]
}

View File

@@ -0,0 +1,94 @@
{
"triggers": [
{
"uid": "rules.HttpAction",
"label": "Remove rule",
"description": "Removes a rule permanently",
"visibility": "VISIBLE",
"configDescriptions": [
{
"name": "method",
"type": "TEXT",
"label": "Http method",
"description": "The http method to be used",
"required": true,
"default": "GET",
"limitToOptions": true,
"options": [
{
"label": "GET",
"value": "GET"
},
{
"label": "POST",
"value": "POST"
},
{
"label": "PUT",
"value": "PUT"
},
{
"label": "HEAD",
"value": "HEAD"
},
{
"label": "DELETE",
"value": "DELETE"
}
]
},
{
"name": "url",
"type": "TEXT",
"context": "url",
"label": "URL",
"description": "The url that the http request should be done on. Can be a relative one, starting with '/'. For example '/api/foo/bar'",
"required": true
},
{
"name": "body",
"type": "TEXT",
"context": "body",
"label": "Data",
"description": "For post and put request you can send data with the request",
"required": false
},
{
"name": "mimetype",
"type": "TEXT",
"context": "body",
"label": "Mimetype",
"description": "For post and put request you can send data with the request. Set the mimetype of that date here.",
"required": false,
"limitToOptions": false,
"options": [
{
"label": "Text",
"value": "text/plain"
},
{
"label": "Json",
"value": "application/json"
},
{
"label": "XML",
"value": "application/xml"
},
{
"label": "Binary",
"value": "application/octet-stream"
}
]
},
{
"name": "timeout",
"type": "INTEGER",
"label": "Timeout in sec",
"description": "The timeout of this request in seconds.",
"required": false,
"default": "5"
}
]
}
]
}

View File

@@ -0,0 +1,72 @@
{
"triggers": [
{
"uid": "hue.ruleCondition",
"label": "A condition especially for Hue rules",
"description": "Depending on the operator and item state this condition will be satisfied or not",
"visibility": "HIDDEN",
"configDescriptions": [
{
"name": "operator",
"type": "TEXT",
"label": "Operator",
"description": "The operator that compares either the hue bridge time or a light/sensor state with a given static value",
"required": true,
"limitToOptions": true,
"options": [
{
"label": "Equal",
"value": "eq"
},
{
"label": "Greater than",
"value": "gt"
},
{
"label": "Lower than",
"value": "lt"
},
{
"label": "On item state change",
"value": "dx"
},
{
"label": "On item state change, delayed",
"value": "ddx"
},
{
"label": "Item has been updated but did not change",
"value": "stable"
},
{
"label": "Item has been updated and has changed",
"value": "not_stable"
},
{
"label": "The current time is in range of the given time",
"value": "in"
},
{
"label": "The current time is NOT in range of the given time",
"value": "not_in"
}
]
},
{
"name": "address",
"type": "TEXT",
"label": "Hue link address",
"description": "A hue local link address like /sensors/2/state/buttonevent or /config/localtime",
"required": true
},
{
"name": "value",
"type": "TEXT",
"label": "An optional compare value",
"description": "Only valid for eq,lg,lt operators and a number or boolean item type or in,not_in. For the later it must be in the format of W127/T12:12:12/T14:00:00 describing the valid weekdays (binary encoded), the start time and stop time.",
"required": false
}
]
}
]
}

View File

@@ -0,0 +1,20 @@
{
"triggers": [
{
"uid": "rules.RemoveRuleAction",
"label": "Remove rule",
"description": "Removes a rule permanently",
"visibility": "VISIBLE",
"configDescriptions": [
{
"name": "removeuid",
"type": "TEXT",
"context": "rule",
"label": "Rule",
"description": "The rule that should be removed",
"required": true
}
]
}
]
}

View File

@@ -0,0 +1,36 @@
{
"triggers": [
{
"uid": "timer.TimerTrigger",
"label": "Timer Trigger",
"description": "This triggers a rule based on a timer",
"visibility": "VISIBLE",
"configDescriptions": [
{
"name": "time",
"type": "TEXT",
"context": "time",
"label": "Duration",
"description": "A duration before this timer expires with the pattern hh:mm:ss. The shortest duration is therefore 1 second, the longest is 99 hours.",
"required": true
},
{
"name": "randomizeTime",
"type": "TEXT",
"context": "time",
"label": "Duration Upper Bound",
"description": "An optional upper bound duration before this timer expires with the pattern hh:mm:ss. A random duration between Duration and Duration Upper Bound will be chosen.",
"required": false
},
{
"name": "repeat",
"type": "INTEGER",
"label": "Repeat",
"description": "You can make this timer a recurring timer by setting a value above 1. The default is 1. If you set a value below 0 like -1 this timer will be repeated indefinitely",
"default": "1",
"required": false
}
]
}
]
}

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0
https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="io:hueemulation">
<parameter name="pairingEnabled" type="boolean">
<label>Device Pairing</label>
<description>Pairing must be enabled to connect a new device. Pairing is automatically disabled after the configured
pairing time (usually 60 seconds).</description>
<default>false</default>
</parameter>
<parameter name="pairingTimeout" type="integer" required="false">
<label>Pairing Timeout</label>
<description>Pairing is automatically disabled after the given time in seconds.</description>
<default>60</default>
<advanced>true</advanced>
</parameter>
<parameter name="createNewUserOnEveryEndpoint" type="boolean">
<label>Pairing: Add Unknown User-keys</label>
<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.</description>
<default>false</default>
</parameter>
<parameter name="temporarilyEmulateV1bridge" type="boolean">
<label>Pairing: Temporarily Emulate V1 Hue Bridge</label>
<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.</description>
<default>false</default>
</parameter>
<parameter name="permanentV1bridge" type="boolean">
<label>Permanently Emulate V1 Hue Bridge</label>
<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.</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
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
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
by tags. Use commas to separate multiple entries.</description>
<default>Lighting</default>
</parameter>
<parameter name="ignoreItemsWithTags" type="text" required="false">
<label>Ignore Items by Tag</label>
<description>All items that are tagged with the given tags are ignore by the Hue Emulation Service. Use commas to
separate multiple entries.</description>
<default>internal</default>
</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
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">
<label>Optional Discovery Web Port</label>
<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.
</description>
<advanced>true</advanced>
</parameter>
<parameter name="uuid" type="text" required="false">
<label>Unique Bridge ID</label>
<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.</description>
<advanced>true</advanced>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>%s/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
<friendlyName>openHAB Hue Emulation (%s)</friendlyName>
<manufacturer>Royal Philips Electronics</manufacturer>
<manufacturerURL>http://www.openhab.org</manufacturerURL>
<modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
<modelName>Philips hue bridge 2015</modelName>
<modelNumber>BSB002</modelNumber>
<modelURL>http://www.meethue.com</modelURL>
<serialNumber>%s</serialNumber>
<UDN>uuid:%s</UDN>
<gatewayName>%s</gatewayName>
<presentationURL>index.html</presentationURL>
<iconList>
<icon>
<mimetype>image/png</mimetype>
<height>48</height>
<width>48</width>
<depth>24</depth>
<url>hue_logo_0.png</url>
</icon>
<icon>
<mimetype>image/png</mimetype>
<height>120</height>
<width>120</width>
<depth>24</depth>
<url>hue_logo_3.png</url>
</icon>
</iconList>
</device>
</root>

View File

@@ -0,0 +1,213 @@
/**
* Copyright (c) 2010-2020 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.automation;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.items.GroupItem;
import org.openhab.core.library.items.ContactItem;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.core.automation.Condition;
import org.openhab.core.automation.util.ConditionBuilder;
import org.openhab.io.hueemulation.internal.DeviceType;
import org.openhab.io.hueemulation.internal.RuleUtils;
import org.openhab.io.hueemulation.internal.dto.HueDataStore;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueSensorEntry;
/**
* Test the {@link HueRuleConditionHandler}.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class RuleConditionHandlerTests {
protected @NonNullByDefault({}) HueDataStore ds;
private static class HueRuleConditionHandlerEx extends HueRuleConditionHandler {
LocalDateTime dt = LocalDateTime.of(2019, 04, 12, 12, 12, 12);
public HueRuleConditionHandlerEx(Condition module, HueDataStore ds) {
super(module, ds);
}
@Override
protected LocalDateTime getNow() {
return dt;
}
}
@Before
public void setUp() {
ds = new HueDataStore();
ds.lights.put("1", new HueLightEntry(new SwitchItem("switch"), "switch", DeviceType.SwitchType));
ds.sensors.put("2", new HueSensorEntry(new ContactItem("contact")));
ds.groups.put("10",
new HueGroupEntry("name", new GroupItem("white", new NumberItem("number")), DeviceType.SwitchType));
}
@After
public void tearDown() {
RuleUtils.random = new Random();
}
@Test(expected = IllegalStateException.class)
public void itemNotExisting() {
Configuration configuration = new Configuration();
configuration.put("address", "/groups/9/action");
configuration.put("operator", "dx");
configuration.put("value", "");
Condition c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
new HueRuleConditionHandler(c, ds);
}
@Test
public void itemAccept() {
Condition c;
Configuration configuration = new Configuration();
configuration.put("operator", "dx");
configuration.put("value", "");
configuration.put("address", "/groups/10/action");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
new HueRuleConditionHandler(c, ds);
configuration.put("address", "/lights/1/state");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
new HueRuleConditionHandler(c, ds);
configuration.put("address", "/sensors/2/state");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
new HueRuleConditionHandler(c, ds);
}
@Test
public void timeRangeAccept() {
Condition c;
Configuration configuration = new Configuration();
configuration.put("address", "/config/localtime");
configuration.put("operator", "in");
configuration.put("value", "T12:12:10/T12:12:50");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
HueRuleConditionHandlerEx subject = new HueRuleConditionHandlerEx(c, ds);
assertThat(subject.isSatisfied(Collections.emptyMap()), is(true));
configuration.put("address", "/config/localtime");
configuration.put("operator", "not_in");
configuration.put("value", "W8/T12:12:10/T12:12:50");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
subject = new HueRuleConditionHandlerEx(c, ds);
assertThat(subject.isSatisfied(Collections.emptyMap()), is(false));
}
@Test
public void equalOperator() {
Map<String, Object> context = new TreeMap<>();
HueRuleConditionHandler subject;
Condition c;
Configuration configuration = new Configuration();
configuration.put("operator", "eq");
context.put("newState", OnOffType.ON);
context.put("oldState", OnOffType.OFF);
configuration.put("value", "true");
configuration.put("address", "/lights/1/state");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
subject = new HueRuleConditionHandler(c, ds);
assertThat(subject.isSatisfied(context), is(true));
context.put("newState", OpenClosedType.OPEN);
context.put("oldState", OpenClosedType.CLOSED);
configuration.put("value", "true");
configuration.put("address", "/sensors/2/state");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
subject = new HueRuleConditionHandler(c, ds);
assertThat(subject.isSatisfied(context), is(true));
context.put("newState", new DecimalType(12));
context.put("oldState", new DecimalType(0));
configuration.put("value", "12");
configuration.put("address", "/groups/10/action");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
subject = new HueRuleConditionHandler(c, ds);
assertThat(subject.isSatisfied(context), is(true));
}
@Test
public void gtOperator() {
Map<String, Object> context = new TreeMap<>();
HueRuleConditionHandler subject;
Condition c;
Configuration configuration = new Configuration();
configuration.put("operator", "gt");
context.put("newState", new DecimalType(12));
context.put("oldState", new DecimalType(0));
configuration.put("value", "10");
configuration.put("address", "/groups/10/action");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
subject = new HueRuleConditionHandler(c, ds);
assertThat(subject.isSatisfied(context), is(true));
}
@Test
public void ltOperator() {
Map<String, Object> context = new TreeMap<>();
HueRuleConditionHandler subject;
Condition c;
Configuration configuration = new Configuration();
configuration.put("operator", "lt");
context.put("newState", new DecimalType(12));
context.put("oldState", new DecimalType(0));
configuration.put("value", "15");
configuration.put("address", "/groups/10/action");
c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID)
.withConfiguration(configuration).build();
subject = new HueRuleConditionHandler(c, ds);
assertThat(subject.isSatisfied(context), is(true));
}
}

View File

@@ -0,0 +1,162 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.util.Collections;
import java.util.Dictionary;
import java.util.Hashtable;
import java.util.concurrent.ScheduledExecutorService;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import org.openhab.core.events.EventPublisher;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory;
import org.glassfish.jersey.logging.LoggingFeature;
import org.glassfish.jersey.logging.LoggingFeature.Verbosity;
import org.glassfish.jersey.server.ResourceConfig;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.rest.mocks.ConfigStoreWithoutMetadata;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyMetadataRegistry;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyUsersStorage;
import org.osgi.service.cm.ConfigurationAdmin;
/**
* We have no OSGi framework in the background. This class resolves
* dependencies between the different classes and mocks common services like the configAdmin.
* <p>
* The {@link UserManagement} rest components is always
* setup and started in this common test setup, because all other rest components require
* user authentication.
*
* @author David Graeff - Initial contribution
*/
public class CommonSetup {
public UserManagement userManagement;
@Mock
public EventPublisher eventPublisher;
public ConfigStore cs;
@Mock
ConfigurationAdmin configAdmin;
@Mock
ScheduledExecutorService scheduler;
@Mock
org.osgi.service.cm.Configuration configAdminConfig;
@Mock
NetworkAddressService networkAddressService;
MetadataRegistry metadataRegistry = new DummyMetadataRegistry();
StorageService storageService = new StorageService() {
@Override
public <T> Storage<T> getStorage(String name, ClassLoader classLoader) {
return getStorage(name);
}
@SuppressWarnings("unchecked")
@Override
public <T> Storage<T> getStorage(String name) {
if (name.equals("hueEmulationUsers")) {
return (Storage<T>) new DummyUsersStorage();
}
throw new IllegalStateException();
}
};
public Client client;
public HttpServer server;
public String basePath;
public CommonSetup(boolean withMetadata) throws IOException {
MockitoAnnotations.initMocks(this);
when(configAdmin.getConfiguration(anyString())).thenReturn(configAdminConfig);
when(configAdmin.getConfiguration(anyString(), any())).thenReturn(configAdminConfig);
Dictionary<String, Object> mockProperties = new Hashtable<>();
when(configAdminConfig.getProperties()).thenReturn(mockProperties);
when(networkAddressService.getPrimaryIpv4HostAddress()).thenReturn("127.0.0.1");
// If anything is scheduled, immediately run it instead
when(scheduler.schedule(any(Runnable.class), anyLong(), any())).thenAnswer(answer -> {
((Runnable) answer.getArgument(0)).run();
return null;
});
if (withMetadata) {
cs = new ConfigStore(networkAddressService, configAdmin, metadataRegistry, scheduler);
} else {
cs = new ConfigStoreWithoutMetadata(networkAddressService, configAdmin, scheduler);
}
cs.activate(Collections.singletonMap("uuid", "demouuid"));
cs.switchFilter = Collections.singleton("Switchable");
cs.whiteFilter = Collections.singleton("Switchable");
cs.colorFilter = Collections.singleton("ColorLighting");
userManagement = Mockito.spy(new UserManagement(storageService, cs));
try (ServerSocket serverSocket = new ServerSocket()) {
serverSocket.bind(new InetSocketAddress(0));
basePath = "http://localhost:" + serverSocket.getLocalPort() + "/api";
}
}
/**
* Start the http server to serve all registered jax-rs resources. Also setup a client for testing, see
* {@link #client}.
*
* @param rc A resource config. Add objects and object instance resources to your needs. Example:
* "new ResourceConfig().registerInstances(configurationAccess)"
*/
public void start(ResourceConfig resourceConfig) {
ResourceConfig rc = resourceConfig.registerInstances(userManagement).register(new LoggingFeature(
Logger.getLogger(LoggingFeature.DEFAULT_LOGGER_NAME), Level.OFF, Verbosity.HEADERS_ONLY, 10));
Logger log2 = Logger.getLogger("org.glassfish");
log2.setLevel(Level.OFF);
server = GrizzlyHttpServerFactory.createHttpServer(URI.create(basePath), rc);
client = ClientBuilder.newClient();
}
public void dispose() {
if (client != null) {
client.close();
}
if (server != null) {
server.shutdownNow();
}
}
}

View File

@@ -0,0 +1,119 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.util.Collections;
import java.util.concurrent.ScheduledExecutorService;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.library.items.SwitchItem;
import org.glassfish.jersey.server.ResourceConfig;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueStatePlug;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyItemRegistry;
/**
* Tests for the metadata provided hue ID mapping
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class ItemUIDtoHueIDMappingTests {
protected @NonNullByDefault({}) CommonSetup commonSetup;
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
LightsAndGroups lightsAndGroups = new LightsAndGroups();
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(true);
commonSetup.start(new ResourceConfig());
itemRegistry = new DummyItemRegistry();
lightsAndGroups.cs = commonSetup.cs;
lightsAndGroups.eventPublisher = commonSetup.eventPublisher;
lightsAndGroups.userManagement = commonSetup.userManagement;
lightsAndGroups.itemRegistry = itemRegistry;
lightsAndGroups.activate();
}
@After
public void tearDown() {
commonSetup.dispose();
}
@Test
public void determineHighestHueID() {
ConfigStore cs = new ConfigStore(commonSetup.networkAddressService, commonSetup.configAdmin,
commonSetup.metadataRegistry, mock(ScheduledExecutorService.class));
// Pretend there is a metadata entry for the imaginary item "demo1" with hueid 10
commonSetup.metadataRegistry.add(new Metadata(new MetadataKey(ConfigStore.METAKEY, "demo1"), "10", null));
cs.activate(Collections.singletonMap("uuid", "demouuid"));
assertThat(cs.getHighestAssignedHueID(), CoreMatchers.is(10));
}
@Test
public void mapItemWithoutHueID() {
ConfigStore cs = commonSetup.cs;
assertThat(cs.getHighestAssignedHueID(), CoreMatchers.is(1));
SwitchItem item = new SwitchItem("switch1");
item.setCategory("Light");
itemRegistry.add(item);
String hueID = cs.mapItemUIDtoHueID(item);
assertThat(hueID, CoreMatchers.is("2"));
HueLightEntry device = cs.ds.lights.get(hueID);
assertThat(device.item, is(item));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
assertThat(cs.getHighestAssignedHueID(), CoreMatchers.is(2));
}
@Test
public void mapItemWithHueID() {
ConfigStore cs = commonSetup.cs;
assertThat(cs.getHighestAssignedHueID(), CoreMatchers.is(1));
SwitchItem item = new SwitchItem("switch1");
item.setCategory("Light");
commonSetup.metadataRegistry.add(new Metadata(new MetadataKey(ConfigStore.METAKEY, "switch1"), "10", null));
itemRegistry.add(item);
String hueID = cs.mapItemUIDtoHueID(item);
assertThat(hueID, CoreMatchers.is("10"));
HueLightEntry device = cs.ds.lights.get(hueID);
assertThat(device.item, is(item));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
assertThat(cs.getHighestAssignedHueID(), CoreMatchers.is(1));
}
}

View File

@@ -0,0 +1,347 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.events.Event;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.events.ItemCommandEvent;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.glassfish.jersey.server.ResourceConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.DeviceType;
import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueStateColorBulb;
import org.openhab.io.hueemulation.internal.dto.HueStatePlug;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyItemRegistry;
/**
* Tests for {@link LightsAndGroups}.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class LightsAndGroupsTests {
protected @NonNullByDefault({}) CommonSetup commonSetup;
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
protected @NonNullByDefault({}) ConfigStore cs;
LightsAndGroups subject = new LightsAndGroups();
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(false);
itemRegistry = new DummyItemRegistry();
this.cs = commonSetup.cs;
subject.cs = cs;
subject.eventPublisher = commonSetup.eventPublisher;
subject.userManagement = commonSetup.userManagement;
subject.itemRegistry = itemRegistry;
subject.activate();
// Add simulated lights
cs.ds.lights.put("1", new HueLightEntry(new SwitchItem("switch"), "switch", DeviceType.SwitchType));
cs.ds.lights.put("2", new HueLightEntry(new ColorItem("color"), "color", DeviceType.ColorType));
cs.ds.lights.put("3", new HueLightEntry(new ColorItem("white"), "white", DeviceType.WhiteTemperatureType));
// Add group item
cs.ds.groups.put("10",
new HueGroupEntry("name", new GroupItem("white", new SwitchItem("switch")), DeviceType.SwitchType));
commonSetup.start(new ResourceConfig().registerInstances(subject));
}
@After
public void tearDown() {
commonSetup.dispose();
}
@Test
public void addSwitchableByCategory() {
SwitchItem item = new SwitchItem("switch1");
item.setCategory("Light");
itemRegistry.add(item);
HueLightEntry device = cs.ds.lights.get(cs.mapItemUIDtoHueID(item));
assertThat(device.item, is(item));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
}
@Test
public void addSwitchableByTag() {
SwitchItem item = new SwitchItem("switch1");
item.addTag("Switchable");
itemRegistry.add(item);
HueLightEntry device = cs.ds.lights.get(cs.mapItemUIDtoHueID(item));
assertThat(device.item, is(item));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
}
@Test
public void ignoreByTag() {
SwitchItem item = new SwitchItem("switch1");
item.addTags("Switchable", "internal"); // The ignore tag will win
itemRegistry.add(item);
HueLightEntry device = cs.ds.lights.get(cs.mapItemUIDtoHueID(item));
assertThat(device, is(nullValue()));
}
@Test
public void addGroupSwitchableByTag() {
GroupItem item = new GroupItem("group1", new SwitchItem("switch1"));
item.addTag("Switchable");
itemRegistry.add(item);
HueGroupEntry device = cs.ds.groups.get(cs.mapItemUIDtoHueID(item));
assertThat(device.groupItem, is(item));
assertThat(device.action, is(instanceOf(HueStatePlug.class)));
}
@Test
public void addDeviceAsGroupSwitchableByTag() {
GroupItem item = new GroupItem("group1", new SwitchItem("switch1"));
item.addTag("Switchable");
item.addTag("Huelight");
itemRegistry.add(item);
HueLightEntry device = cs.ds.lights.get(cs.mapItemUIDtoHueID(item));
assertThat(device.item, is(item));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
}
@Test
public void addGroupWithoutTypeByTag() {
GroupItem item = new GroupItem("group1", null);
item.addTag("Switchable");
itemRegistry.add(item);
HueGroupEntry device = cs.ds.groups.get(cs.mapItemUIDtoHueID(item));
assertThat(device.groupItem, is(item));
assertThat(device.action, is(instanceOf(HueStatePlug.class)));
assertThat(cs.ds.groups.get(cs.mapItemUIDtoHueID(item)).groupItem, is(item));
}
@Test
public void removeGroupWithoutTypeAndTag() {
String groupName = "group1";
GroupItem item = new GroupItem(groupName, null);
item.addTag("Switchable");
itemRegistry.add(item);
String hueID = cs.mapItemUIDtoHueID(item);
assertThat(cs.ds.groups.get(hueID), notNullValue());
subject.updated(item, new GroupItem(groupName, null));
assertThat(cs.ds.groups.get(hueID), nullValue());
}
@Test
public void updateSwitchable() {
SwitchItem item = new SwitchItem("switch1");
item.setLabel("labelOld");
item.addTag("Switchable");
itemRegistry.add(item);
String hueID = cs.mapItemUIDtoHueID(item);
HueLightEntry device = cs.ds.lights.get(hueID);
assertThat(device.item, is(item));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
assertThat(device.name, is("labelOld"));
SwitchItem newitem = new SwitchItem("switch1");
newitem.setLabel("labelNew");
newitem.addTag("Switchable");
subject.updated(item, newitem);
device = cs.ds.lights.get(hueID);
assertThat(device.item, is(newitem));
assertThat(device.state, is(instanceOf(HueStatePlug.class)));
assertThat(device.name, is("labelNew"));
// Update with an item that has no tags anymore -> should be removed
SwitchItem newitemWithoutTag = new SwitchItem("switch1");
newitemWithoutTag.setLabel("labelNew2");
subject.updated(newitem, newitemWithoutTag);
device = cs.ds.lights.get(hueID);
assertThat(device, nullValue());
}
@Test
public void changeSwitchState() {
assertThat(((HueStatePlug) cs.ds.lights.get("1").state).on, is(false));
String body = "{'on':true}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/1/state").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
assertThat(((HueStatePlug) cs.ds.lights.get("1").state).on, is(true));
verify(commonSetup.eventPublisher).post(argThat((Event t) -> {
assertThat(t.getPayload(), is("{\"type\":\"OnOff\",\"value\":\"ON\"}"));
return true;
}));
}
@Test
public void changeGroupItemSwitchState() {
assertThat(((HueStatePlug) cs.ds.groups.get("10").action).on, is(false));
String body = "{'on':true}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/groups/10/action").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
assertThat(((HueStatePlug) cs.ds.groups.get("10").action).on, is(true));
verify(commonSetup.eventPublisher).post(argThat((Event t) -> {
assertThat(t.getPayload(), is("{\"type\":\"OnOff\",\"value\":\"ON\"}"));
return true;
}));
}
@Test
public void changeOnValue() {
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(false));
String body = "{'on':true}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/2/state").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
String entity = response.readEntity(String.class);
assertThat(entity, is("[{\"success\":{\"/lights/2/state/on\":true}}]"));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(true));
}
@Test
public void changeOnAndBriValues() {
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(false));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).bri, is(1));
String body = "{'on':true,'bri':200}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/2/state").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(true));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).bri, is(200));
}
@Test
public void changeHueSatValues() {
HueLightEntry hueDevice = cs.ds.lights.get("2");
hueDevice.item.setState(OnOffType.ON);
hueDevice.state.as(HueStateColorBulb.class).on = true;
String body = "{'hue':1000,'sat':50}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/2/state").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(true));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).hue, is(1000));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).sat, is(50));
verify(commonSetup.eventPublisher).post(argThat(ce -> assertHueValue((ItemCommandEvent) ce, 1000)));
}
/**
* Amazon echos are setting ct only, if commanded to turn a light white.
*/
@Test
public void changeCtValue() {
HueLightEntry hueDevice = cs.ds.lights.get("2");
hueDevice.item.setState(OnOffType.ON);
hueDevice.state.as(HueStateColorBulb.class).on = true;
String body = "{'ct':500}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/2/state").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
body = response.readEntity(String.class);
assertThat(body, containsString("success"));
assertThat(body, containsString("ct"));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(true));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).ct, is(500));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).sat, is(0));
// Saturation is expected to be 0 -> white light
verify(commonSetup.eventPublisher).post(argThat(ce -> assertSatValue((ItemCommandEvent) ce, 0)));
}
@Test
public void switchOnWithXY() {
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(false));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).bri, is(1));
String body = "{'on':true,'bri':200,'xy':[0.5119,0.4147]}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/2/state").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(true));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).bri, is(200));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).xy[0], is(0.5119));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).xy[1], is(0.4147));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).colormode, is(HueStateColorBulb.ColorMode.xy));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).toHSBType().getHue().intValue(),
is((int) 27.47722590981918));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).toHSBType().getSaturation().intValue(), is(88));
assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).toHSBType().getBrightness().intValue(), is(78));
}
@Test
public void allLightsAndSingleLight() {
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights").request().get();
assertEquals(200, response.getStatus());
String body = response.readEntity(String.class);
assertThat(body, containsString("switch"));
assertThat(body, containsString("color"));
assertThat(body, containsString("white"));
// Single light access test
response = commonSetup.client.target(commonSetup.basePath + "/testuser/lights/2").request().get();
assertEquals(200, response.getStatus());
body = response.readEntity(String.class);
assertThat(body, containsString("color"));
}
private boolean assertHueValue(ItemCommandEvent ce, int hueValue) {
assertThat(((HSBType) ce.getItemCommand()).getHue().intValue(), is(hueValue * 360 / HueStateColorBulb.MAX_HUE));
return true;
}
private boolean assertSatValue(ItemCommandEvent ce, int satValue) {
assertThat(((HSBType) ce.getItemCommand()).getSaturation().intValue(),
is(satValue * 100 / HueStateColorBulb.MAX_SAT));
return true;
}
}

View File

@@ -0,0 +1,279 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.GenericItem;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.types.State;
import org.glassfish.jersey.server.ResourceConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.core.automation.Condition;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.RuleUtils;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry;
import org.openhab.io.hueemulation.internal.dto.HueRuleEntry.Operator;
import org.openhab.io.hueemulation.internal.dto.HueSceneEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyItemRegistry;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyRuleRegistry;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Tests for various rules API endpoints.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class RulesTests {
protected @NonNullByDefault({}) CommonSetup commonSetup;
protected @NonNullByDefault({}) ConfigStore cs;
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
Rules subject = new Rules();
LightsAndGroups lightsAndGroups = new LightsAndGroups();
private void addItemToReg(GenericItem item, State state, String tag, String label) {
item.setState(state);
item.setLabel(label);
item.addTag(tag);
itemRegistry.add(item);
}
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(false);
this.cs = commonSetup.cs;
itemRegistry = new DummyItemRegistry();
ruleRegistry = new DummyRuleRegistry();
subject.cs = commonSetup.cs;
subject.userManagement = commonSetup.userManagement;
subject.ruleRegistry = ruleRegistry;
subject.itemRegistry = itemRegistry;
subject.activate();
// We need the LightsAndGroups class to convert registry entries into HueDatastore
// light entries
lightsAndGroups.cs = cs;
lightsAndGroups.eventPublisher = commonSetup.eventPublisher;
lightsAndGroups.userManagement = commonSetup.userManagement;
lightsAndGroups.itemRegistry = itemRegistry;
lightsAndGroups.activate();
addItemToReg(new SwitchItem("switch1"), OnOffType.ON, "Switchable", "name1");
addItemToReg(new SwitchItem("switch2"), OnOffType.ON, "Switchable", "name2");
addItemToReg(new ColorItem("color1"), HSBType.BLUE, "ColorLighting", "");
commonSetup.start(new ResourceConfig().registerInstances(subject));
}
@After
public void tearDown() {
RuleUtils.random = new Random();
commonSetup.dispose();
}
@Test
public void addUpdateRemoveScheduleToRegistry() {
assertThat(cs.ds.lights.get("switch1"), is(notNullValue()));
HueCommand command = new HueCommand("/api/testuser/lights/switch1/state", "PUT", "{'on':true}");
HueRuleEntry.Condition condition = new HueRuleEntry.Condition("/lights/switch1/state/on", Operator.dx, null);
Entry<Trigger, Condition> triggerCond = Rules.hueConditionToAutomation(command.address.replace("/", "-"),
condition, itemRegistry);
Rule rule = RuleBuilder.create("demo1").withName("test name").withTags(Rules.RULES_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(triggerCond.getKey()).withConditions(triggerCond.getValue()).build();
ruleRegistry.add(rule);
// Check hue entry
HueRuleEntry entry = cs.ds.rules.get("demo1");
assertThat(entry.conditions.get(0).address, is("/lights/switch1/state/on"));
assertThat(entry.conditions.get(0).operator, is(Operator.dx));
assertThat(entry.actions.get(0).address, is("/lights/switch1/state"));
assertThat(entry.actions.get(0).method, is("PUT"));
assertThat(entry.actions.get(0).body, is("{'on':true}"));
// Update
command = new HueCommand("/api/testuser/lights/switch2/state", "PUT", "{'on':false}");
rule = RuleBuilder.create("demo1").withName("name2").withTags(Rules.RULES_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(triggerCond.getKey()).withConditions(triggerCond.getValue()).build();
ruleRegistry.update(rule);
entry = cs.ds.rules.get("demo1");
assertThat(entry.actions.get(0).address, is("/lights/switch2/state"));
assertThat(entry.actions.get(0).method, is("PUT"));
assertThat(entry.actions.get(0).body, is("{'on':false}"));
assertThat(entry.name, is("name2"));
// Remove
ruleRegistry.remove("demo1");
entry = cs.ds.rules.get("demo1");
assertThat(entry, nullValue());
}
@SuppressWarnings("null")
@Test
public void addGetRemoveRuleViaRest() {
// 1. Create
String body = "{\"name\":\"test name\",\"description\":\"\",\"owner\":\"\",\"conditions\":[{\"address\":\"/lights/switch1/state/on\",\"operator\":\"dx\"}],\"actions\":[{\"address\":\"/lights/switch1/state\",\"method\":\"PUT\",\"body\":\"{\\u0027on\\u0027:true}\"}]}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules").request()
.post(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
// 1.1 Check for entry
Entry<String, HueRuleEntry> idAndEntry = cs.ds.rules.entrySet().stream().findAny().get();
HueRuleEntry entry = idAndEntry.getValue();
assertThat(entry.name, is("test name"));
assertThat(entry.actions.get(0).address, is("/lights/switch1/state"));
assertThat(entry.conditions.get(0).address, is("/lights/switch1/state/on"));
// 1.2 Check for rule
Rule rule = ruleRegistry.get(idAndEntry.getKey());
assertThat(rule.getName(), is("test name"));
assertThat(rule.getActions().get(0).getId(), is("-api-testuser-lights-switch1-state"));
assertThat(rule.getActions().get(0).getTypeUID(), is("rules.HttpAction"));
// 2. Get
response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules/" + idAndEntry.getKey()).request()
.get();
assertEquals(200, response.getStatus());
HueSceneEntry fromJson = new Gson().fromJson(response.readEntity(String.class), HueSceneEntry.class);
assertThat(fromJson.name, is(idAndEntry.getValue().name));
// 3. Remove
response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules/" + idAndEntry.getKey()).request()
.delete();
assertEquals(200, response.getStatus());
assertTrue(cs.ds.rules.isEmpty());
}
@Test
public void updateRuleViaRest() {
HueCommand command = new HueCommand("/api/testuser/lights/switch1/state", "PUT", "{'on':true}");
HueRuleEntry.Condition condition = new HueRuleEntry.Condition("/lights/switch1/state/on", Operator.dx, null);
Entry<Trigger, Condition> triggerCond = Rules.hueConditionToAutomation(command.address.replace("/", "-"),
condition, itemRegistry);
Rule rule = RuleBuilder.create("demo1").withName("test name").withTags(Rules.RULES_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(triggerCond.getKey()).withConditions(triggerCond.getValue()).build();
ruleRegistry.add(rule);
// Modify (just the name)
String body = "{ 'name':'A new name'}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("name"));
Entry<String, HueRuleEntry> idAndEntry = cs.ds.rules.entrySet().stream().findAny().get();
HueRuleEntry entry = idAndEntry.getValue();
assertThat(entry.name, is("A new name"));
assertThat(entry.actions.get(0).address, is("/lights/switch1/state"));
assertThat(entry.conditions.get(0).address, is("/lights/switch1/state/on"));
// Reset
rule = RuleBuilder.create("demo1").withName("test name").withTags(Rules.RULES_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(triggerCond.getKey()).withConditions(triggerCond.getValue()).build();
ruleRegistry.update(rule); // Reset rule
idAndEntry = cs.ds.rules.entrySet().stream().findAny().get();
// Modify (Change condition)
body = "{\"conditions\":[{\"address\":\"/lights/switch1/state/on\",\"operator\":\"ddx\"}]}";
response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("conditions"));
idAndEntry = cs.ds.rules.entrySet().stream().findAny().get();
entry = idAndEntry.getValue();
assertThat(entry.name, is("test name")); // should not have changed
assertThat(entry.conditions.get(0).operator, is(Operator.ddx));
// Modify (Change action)
body = "{\"actions\":[{\"address\":\"/lights/switch2/state\",\"method\":\"PUT\",\"body\":\"{\\u0027on\\u0027:false}\"}]}";
response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("actions"));
idAndEntry = cs.ds.rules.entrySet().stream().findAny().get();
entry = idAndEntry.getValue();
assertThat(entry.name, is("test name")); // should not have changed
assertThat(entry.actions.get(0).address, is("/lights/switch2/state"));
}
@Test
public void getAll() {
HueCommand command = new HueCommand("/api/testuser/lights/switch1/state", "PUT", "{'on':true}");
HueRuleEntry.Condition condition = new HueRuleEntry.Condition("/lights/switch1/state/on", Operator.dx, null);
Entry<Trigger, Condition> triggerCond = Rules.hueConditionToAutomation(command.address.replace("/", "-"),
condition, itemRegistry);
Rule rule = RuleBuilder.create("demo1").withName("test name").withTags(Rules.RULES_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(triggerCond.getKey()).withConditions(triggerCond.getValue()).build();
ruleRegistry.add(rule);
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/rules").request().get();
Type type = new TypeToken<Map<String, HueRuleEntry>>() {
}.getType();
String body = response.readEntity(String.class);
Map<String, HueRuleEntry> fromJson = new Gson().fromJson(body, type);
HueRuleEntry entry = fromJson.get("demo1");
assertThat(entry.name, is("test name"));
assertThat(entry.actions.get(0).address, is("/lights/switch1/state"));
assertThat(entry.conditions.get(0).address, is("/lights/switch1/state/on"));
}
}

View File

@@ -0,0 +1,231 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.Map;
import java.util.Map.Entry;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.GenericItem;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.DimmerItem;
import org.openhab.core.library.items.RollershutterItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.glassfish.jersey.server.ResourceConfig;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.dto.HueSceneEntry;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyItemRegistry;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyRuleRegistry;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Tests for various scene API endpoints.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class SceneTests {
protected @NonNullByDefault({}) CommonSetup commonSetup;
protected @NonNullByDefault({}) ConfigStore cs;
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
Scenes subject = new Scenes();
private void addItemToReg(GenericItem item, State state, String tag) {
item.setState(state);
item.addTag(tag);
itemRegistry.add(item);
}
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(false);
this.cs = commonSetup.cs;
itemRegistry = new DummyItemRegistry();
ruleRegistry = new DummyRuleRegistry();
subject.cs = commonSetup.cs;
subject.userManagement = commonSetup.userManagement;
subject.itemRegistry = itemRegistry;
subject.ruleRegistry = ruleRegistry;
subject.activate();
// Add simulated lights
addItemToReg(new SwitchItem("switch1"), OnOffType.ON, "Switchable");
addItemToReg(new ColorItem("color1"), HSBType.BLUE, "ColorLighting");
addItemToReg(new DimmerItem("white1"), new PercentType(12), "Lighting");
addItemToReg(new RollershutterItem("roller1"), new PercentType(12), "Lighting");
addItemToReg(new DimmerItem("white1"), new PercentType(12), "Lighting");
addItemToReg(new GroupItem("group1"), OnOffType.ON, "Switchable");
commonSetup.start(new ResourceConfig().registerInstances(subject));
}
@After
public void tearDown() {
commonSetup.dispose();
}
@SuppressWarnings("null")
@Test
public void addUpdateRemoveSceneToRegistry() {
Rule rule = RuleBuilder.create("demo1").withTags("scene") //
.withActions(Scenes.actionFromState("switch1", (Command) OnOffType.ON)).build();
ruleRegistry.add(rule);
HueSceneEntry sceneEntry = cs.ds.scenes.get("demo1");
assertThat(sceneEntry.lights.get(0), CoreMatchers.is("switch1"));
// Update
rule = RuleBuilder.create("demo1").withTags("scene") //
.withActions(Scenes.actionFromState("white1", (Command) OnOffType.ON)).build();
ruleRegistry.update(rule);
sceneEntry = cs.ds.scenes.get("demo1");
assertThat(sceneEntry.lights.get(0), CoreMatchers.is("white1"));
// Remove
ruleRegistry.remove("demo1");
sceneEntry = cs.ds.scenes.get("demo1");
assertThat(sceneEntry, CoreMatchers.nullValue());
}
@SuppressWarnings("null")
@Test
public void addGetRemoveSceneViaRest() {
// 1. Create
String body = "{ 'name':'Cozy dinner', 'recycle':false, 'lights':['switch1','white1'], 'type':'LightScene'}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes").request()
.post(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
// 1.1 Check for scene entry
Entry<String, HueSceneEntry> entry = cs.ds.scenes.entrySet().stream().findAny().get();
assertThat(entry.getValue().name, is("Cozy dinner"));
assertThat(entry.getValue().lights.get(0), is("switch1"));
assertThat(entry.getValue().lights.get(1), is("white1"));
// 1.2 Check for rule
Rule rule = ruleRegistry.get(entry.getKey());
assertThat(rule.getName(), is("Cozy dinner"));
assertThat(rule.getActions().get(0).getId(), is("switch1"));
assertThat(rule.getActions().get(1).getId(), is("white1"));
// 2. Get
response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes/" + entry.getKey()).request()
.get();
assertEquals(200, response.getStatus());
HueSceneEntry fromJson = new Gson().fromJson(response.readEntity(String.class), HueSceneEntry.class);
assertThat(fromJson.name, is(entry.getValue().name));
// 3. Remove
response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes/" + entry.getKey()).request()
.delete();
assertEquals(200, response.getStatus());
assertTrue(cs.ds.scenes.isEmpty());
}
@SuppressWarnings("null")
@Test
public void updateSceneViaRest() {
Rule rule = RuleBuilder.create("demo1").withTags("scene").withName("Some name") //
.withActions(Scenes.actionFromState("switch1", (Command) OnOffType.ON)).build();
ruleRegistry.add(rule);
// 3. Modify (just the name)
String body = "{ 'name':'A new name'}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("name"));
Entry<String, HueSceneEntry> sceneEntry = cs.ds.scenes.entrySet().stream().findAny().get();
assertThat(sceneEntry.getValue().name, is("A new name"));
assertThat(sceneEntry.getValue().lights.get(0), is("switch1")); // nothing else should have changed
// 3. Modify (just the lights)
rule = RuleBuilder.create("demo1").withTags("scene").withName("Some name") //
.withActions(Scenes.actionFromState("switch1", (Command) OnOffType.ON)).build();
ruleRegistry.update(rule); // Reset rule
sceneEntry = cs.ds.scenes.entrySet().stream().findAny().get();
String uid = sceneEntry.getKey();
// Without store lights
body = "{ 'lights':['white1']}";
response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("lights"));
sceneEntry = cs.ds.scenes.entrySet().stream().findAny().get();
assertThat(sceneEntry.getValue().name, is("Some name")); // should not have changed
assertThat(sceneEntry.getKey(), is(uid));
assertThat(sceneEntry.getValue().lights.get(0), is("switch1")); // storelightstate not set, lights not changed
// With store lights
body = "{ 'lights':['white1'], 'storelightstate':true }";
response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("lights"));
sceneEntry = cs.ds.scenes.entrySet().stream().findAny().get();
assertThat(sceneEntry.getValue().lights.get(0), is("white1"));
}
@Test
public void getAll() {
Rule rule = RuleBuilder.create("demo1").withTags("scene") //
.withActions(Scenes.actionFromState("switch1", (Command) OnOffType.ON)).build();
ruleRegistry.add(rule);
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/scenes").request().get();
Type type = new TypeToken<Map<String, HueSceneEntry>>() {
}.getType();
Map<String, HueSceneEntry> fromJson = new Gson().fromJson(response.readEntity(String.class), type);
assertTrue(fromJson.containsKey("demo1"));
}
}

View File

@@ -0,0 +1,425 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.Collections;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.SwitchItem;
import org.glassfish.jersey.server.ResourceConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleManager;
import org.openhab.core.automation.RuleRegistry;
import org.openhab.core.automation.Trigger;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.core.automation.util.TriggerBuilder;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.DeviceType;
import org.openhab.io.hueemulation.internal.RuleUtils;
import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
import org.openhab.io.hueemulation.internal.dto.HueSceneEntry;
import org.openhab.io.hueemulation.internal.dto.HueScheduleEntry;
import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyRuleRegistry;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Tests for various schedule API endpoints.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class ScheduleTests {
protected @NonNullByDefault({}) CommonSetup commonSetup;
protected @NonNullByDefault({}) ConfigStore cs;
protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
Schedules subject = new Schedules();
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(false);
this.cs = commonSetup.cs;
ruleRegistry = new DummyRuleRegistry();
subject.cs = commonSetup.cs;
subject.userManagement = commonSetup.userManagement;
subject.ruleManager = mock(RuleManager.class);
when(subject.ruleManager.isEnabled(anyString())).thenReturn(true);
subject.ruleRegistry = ruleRegistry;
subject.activate();
// Add simulated lights
cs.ds.lights.put("1", new HueLightEntry(new SwitchItem("switch"), "switch", DeviceType.SwitchType));
cs.ds.lights.put("2", new HueLightEntry(new ColorItem("color"), "color", DeviceType.ColorType));
cs.ds.lights.put("3", new HueLightEntry(new ColorItem("white"), "white", DeviceType.WhiteTemperatureType));
commonSetup.start(new ResourceConfig().registerInstances(subject));
// Mock random -> always return int=10 or the highest possible int if bounded
Random random = mock(Random.class);
doReturn(10).when(random).nextInt();
doAnswer(a -> {
Integer bound = a.getArgument(0);
return bound - 1;
}).when(random).nextInt(anyInt());
RuleUtils.random = random;
}
@After
public void tearDown() {
RuleUtils.random = new Random();
commonSetup.dispose();
}
@SuppressWarnings("null")
@Test
public void addUpdateRemoveScheduleToRegistry() {
HueCommand command = new HueCommand("/api/testuser/lights/1/state", "PUT", "{'on':true}");
String localtime = "2020-02-01T12:12:00";
Rule rule = RuleBuilder.create("demo1").withName("test name").withTags(Schedules.SCHEDULE_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(RuleUtils.createTriggerForTimeString(localtime)).build();
ruleRegistry.add(rule);
// Check hue entry
HueScheduleEntry sceneEntry = cs.ds.schedules.get("demo1");
assertThat(sceneEntry.command.address, is("/api/testuser/lights/1/state"));
assertThat(sceneEntry.command.method, is("PUT"));
assertThat(sceneEntry.command.body, is("{'on':true}"));
assertThat(sceneEntry.localtime, is(localtime));
// Update
localtime = "2021-03-01T17:12:00";
rule = RuleBuilder.create("demo1").withName("test name").withTags(Schedules.SCHEDULE_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(RuleUtils.createTriggerForTimeString(localtime)).build();
ruleRegistry.update(rule);
sceneEntry = cs.ds.schedules.get("demo1");
assertThat(sceneEntry.command.address, is("/api/testuser/lights/1/state"));
assertThat(sceneEntry.localtime, is(localtime));
// Remove
ruleRegistry.remove("demo1");
sceneEntry = cs.ds.schedules.get("demo1");
assertThat(sceneEntry, nullValue());
}
@SuppressWarnings("null")
@Test
public void addGetRemoveScheduleViaRest() {
// 1. Create
String body = "{ 'name':'Wake up', 'description':'My wake up alarm', 'localtime':'2015-06-30T14:24:40'," + //
"'command':{'address':'/api/testuser/lights/1/state','method':'PUT','body':'{\"on\":true}'} }";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules").request()
.post(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("success"));
// 1.1 Check for entry
Entry<String, HueScheduleEntry> entry = cs.ds.schedules.entrySet().stream().findAny().get();
assertThat(entry.getValue().name, is("Wake up"));
assertThat(entry.getValue().command.address, is("/api/testuser/lights/1/state"));
assertThat(entry.getValue().command.method, is("PUT"));
assertThat(entry.getValue().command.body, is("{\"on\":true}"));
assertThat(entry.getValue().localtime, is("2015-06-30T14:24:40"));
// 1.2 Check for rule
Rule rule = ruleRegistry.get(entry.getKey());
assertThat(rule.getName(), is("Wake up"));
assertThat(rule.getActions().get(0).getId(), is("command"));
assertThat(rule.getActions().get(0).getTypeUID(), is("rules.HttpAction"));
// 2. Get
response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules/" + entry.getKey()).request()
.get();
assertEquals(200, response.getStatus());
HueSceneEntry fromJson = new Gson().fromJson(response.readEntity(String.class), HueSceneEntry.class);
assertThat(fromJson.name, is(entry.getValue().name));
// 3. Remove
response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules/" + entry.getKey()).request()
.delete();
assertEquals(200, response.getStatus());
assertTrue(cs.ds.schedules.isEmpty());
}
@SuppressWarnings("null")
@Test
public void updateScheduleViaRest() {
HueCommand command = new HueCommand("/api/testuser/lights/1/state", "PUT", "{'on':true}");
String localtime = "2020-02-01T12:12:00";
Rule rule = RuleBuilder.create("demo1").withName("test name").withTags(Schedules.SCHEDULE_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(RuleUtils.createTriggerForTimeString(localtime)).build();
ruleRegistry.add(rule);
// Modify (just the name)
String body = "{ 'name':'A new name'}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("name"));
Entry<String, HueScheduleEntry> entry = cs.ds.schedules.entrySet().stream().findAny().get();
assertThat(entry.getValue().name, is("A new name"));
assertThat(entry.getValue().command.address, is("/api/testuser/lights/1/state")); // nothing else should have
// changed
assertThat(entry.getValue().localtime, is(localtime));
// Reset
rule = RuleBuilder.create("demo1").withName("test name").withTags(Schedules.SCHEDULE_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(RuleUtils.createTriggerForTimeString(localtime)).build();
ruleRegistry.update(rule); // Reset rule
entry = cs.ds.schedules.entrySet().stream().findAny().get();
String uid = entry.getKey();
// Modify (Change time)
body = "{ 'localtime':'2015-06-30T14:24:40'}";
response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("localtime"));
entry = cs.ds.schedules.entrySet().stream().findAny().get();
assertThat(entry.getValue().name, is("test name")); // should not have changed
assertThat(entry.getKey(), is(uid));
assertThat(entry.getValue().localtime, is("2015-06-30T14:24:40"));
// Modify (Change command)
body = "{ 'command':{'address':'/api/testuser/lights/2/state','method':'PUT','body':'{\"on\":true}'} }";
response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules/demo1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
assertThat(response.readEntity(String.class), containsString("command"));
entry = cs.ds.schedules.entrySet().stream().findAny().get();
assertThat(entry.getValue().name, is("test name")); // should not have changed
assertThat(entry.getKey(), is(uid));
assertThat(entry.getValue().command.address, is("/api/testuser/lights/2/state"));
}
@Test
public void getAll() {
HueCommand command = new HueCommand("/api/testuser/lights/1/state", "POST", "{'on':true}");
String localtime = "2020-02-01T12:12:00";
Rule rule = RuleBuilder.create("demo1").withName("test name").withTags(Schedules.SCHEDULE_TAG) //
.withActions(RuleUtils.createHttpAction(command, "command")) //
.withTriggers(RuleUtils.createTriggerForTimeString(localtime)).build();
ruleRegistry.add(rule);
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/schedules").request().get();
Type type = new TypeToken<Map<String, HueSceneEntry>>() {
}.getType();
Map<String, HueSceneEntry> fromJson = new Gson().fromJson(response.readEntity(String.class), type);
assertTrue(fromJson.containsKey("demo1"));
}
@Test
public void timeStringToTrigger() {
String timeString;
Trigger trigger;
Configuration configuration;
// absolute time
timeString = "2020-02-01T12:12:00";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.AbsoluteDateTimeTrigger"));
assertThat(configuration.get("date"), is("2020-02-01"));
assertThat(configuration.get("time"), is("12:12:00"));
// absolute randomized time
timeString = "2020-02-01T12:12:00A14:12:34";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.AbsoluteDateTimeTrigger"));
assertThat(configuration.get("date"), is("2020-02-01"));
assertThat(configuration.get("time"), is("12:12:00"));
assertThat(configuration.get("randomizeTime"), is("14:12:34"));
// Recurring times,Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday= 1
// Cron expression: min hour day month weekdays
timeString = "W3/T12:15:17";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.GenericCronTrigger"));
assertThat(configuration.get("cronExpression"), is("15 12 * * 6,7"));
// Recurring randomized times
timeString = "W127/T12:15:17A14:12:34";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.GenericCronTrigger"));
assertThat(configuration.get("cronExpression"), is("15 14 * * 1,2,3,4,5,6,7"));
// Timer, expiring after given time
timeString = "PT12:12:00";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.TimerTrigger"));
assertThat(configuration.get("time"), is("12:12:00"));
// Timer with random element
timeString = "PT12:12:00A14:12:34";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.TimerTrigger"));
assertThat(configuration.get("time"), is("12:12:00"));
assertThat(configuration.get("randomizeTime"), is("14:12:34"));
// Timers, Recurring timer
timeString = "R/PT12:12:00";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.TimerTrigger"));
assertThat(configuration.get("time"), is("12:12:00"));
assertThat(configuration.get("repeat"), is("-1"));
// Recurring timer with random element
timeString = "R12/PT12:12:00A14:12:34";
trigger = RuleUtils.createTriggerForTimeString(timeString);
configuration = trigger.getConfiguration();
assertThat(trigger.getTypeUID(), is("timer.TimerTrigger"));
assertThat(configuration.get("time"), is("12:12:00"));
assertThat(configuration.get("randomizeTime"), is("14:12:34"));
assertThat(configuration.get("repeat"), is("12"));
}
@Test
public void triggerToTimestring() {
String timeString;
Trigger trigger;
Configuration configuration;
// absolute time
configuration = new Configuration();
configuration.put("date", "2020-02-01");
configuration.put("time", "12:12:00");
trigger = TriggerBuilder.create().withId("absolutetrigger").withTypeUID("timer.AbsoluteDateTimeTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("2020-02-01T12:12:00"));
// absolute randomized time
configuration = new Configuration();
configuration.put("date", "2020-02-01");
configuration.put("time", "12:12:00");
configuration.put("randomizeTime", "14:12:34");
trigger = TriggerBuilder.create().withId("absolutetrigger").withTypeUID("timer.AbsoluteDateTimeTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("2020-02-01T12:12:00A14:12:34"));
// Recurring times,Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday= 1
// Cron expression: min hour day month weekdays
configuration = new Configuration();
configuration.put("cronExpression", "15 12 * * 6,7");
trigger = TriggerBuilder.create().withId("crontrigger").withTypeUID("timer.GenericCronTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("W3/T12:15:00"));
// Recurring randomized times (not possible, the cron rule has no way to store that info)
configuration = new Configuration();
configuration.put("cronExpression", "15 14 * * 1,2,3,4,5,6,7");
trigger = TriggerBuilder.create().withId("crontrigger").withTypeUID("timer.GenericCronTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("W127/T14:15:00"));
// Timer, expiring after given time
configuration = new Configuration();
configuration.put("time", "12:12:00");
trigger = TriggerBuilder.create().withId("timertrigger").withTypeUID("timer.TimerTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("PT12:12:00"));
// Timer with random element
configuration = new Configuration();
configuration.put("time", "12:12:00");
configuration.put("randomizeTime", "14:12:34");
trigger = TriggerBuilder.create().withId("timertrigger").withTypeUID("timer.TimerTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("PT12:12:00A14:12:34"));
// Timers, Recurring timer
configuration = new Configuration();
configuration.put("time", "12:12:00");
configuration.put("repeat", -1);
trigger = TriggerBuilder.create().withId("timertrigger").withTypeUID("timer.TimerTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("R/PT12:12:00"));
// Recurring timer with random element
configuration = new Configuration();
configuration.put("time", "12:12:00");
configuration.put("randomizeTime", "14:12:34");
configuration.put("repeat", 12);
trigger = TriggerBuilder.create().withId("timertrigger").withTypeUID("timer.TimerTrigger")
.withConfiguration(configuration).build();
timeString = RuleUtils.timeStringFromTrigger(Collections.singletonList(trigger));
assertThat(timeString, is("R12/PT12:12:00A14:12:34"));
}
}

View File

@@ -0,0 +1,123 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.items.GenericItem;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.ContactItem;
import org.openhab.core.library.items.DimmerItem;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.RollershutterItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.types.State;
import org.glassfish.jersey.server.ResourceConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.rest.mocks.DummyItemRegistry;
/**
* Tests for {@link Sensors}.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class SensorTests {
protected @NonNullByDefault({}) CommonSetup commonSetup;
protected @NonNullByDefault({}) ItemRegistry itemRegistry;
protected @NonNullByDefault({}) ConfigStore cs;
Sensors subject = new Sensors();
private void addItemToReg(GenericItem item, State state, String label) {
item.setState(state);
item.setLabel(label);
itemRegistry.add(item);
}
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(false);
itemRegistry = new DummyItemRegistry();
this.cs = commonSetup.cs;
subject.cs = cs;
subject.userManagement = commonSetup.userManagement;
subject.itemRegistry = itemRegistry;
subject.activate();
// Add simulated sensor items
addItemToReg(new SwitchItem("switch1"), OnOffType.ON, "name1");
addItemToReg(new ContactItem("contact1"), OpenClosedType.OPEN, "");
addItemToReg(new ColorItem("color1"), HSBType.BLUE, "");
addItemToReg(new DimmerItem("white1"), new PercentType(12), "");
addItemToReg(new RollershutterItem("roller1"), new PercentType(12), "");
addItemToReg(new NumberItem("number1"), new DecimalType(12), "");
commonSetup.start(new ResourceConfig().registerInstances(subject));
}
@After
public void tearDown() {
commonSetup.dispose();
}
@Test
public void renameSensor() {
assertThat(cs.ds.sensors.get("switch1").name, is("name1"));
String body = "{'name':'name2'}";
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/sensors/switch1").request()
.put(Entity.json(body));
assertEquals(200, response.getStatus());
body = response.readEntity(String.class);
assertThat(body, containsString("success"));
assertThat(body, containsString("name"));
assertThat(cs.ds.sensors.get("switch1").name, is("name2"));
}
@Test
public void allAndSingleSensor() {
Response response = commonSetup.client.target(commonSetup.basePath + "/testuser/sensors").request().get();
assertEquals(200, response.getStatus());
String body = response.readEntity(String.class);
assertThat(body, containsString("switch1"));
assertThat(body, containsString("color1"));
assertThat(body, containsString("white1"));
// Single light access test
response = commonSetup.client.target(commonSetup.basePath + "/testuser/sensors/switch1").request().get();
assertEquals(200, response.getStatus());
body = response.readEntity(String.class);
assertThat(body, containsString("CLIPGenericFlag"));
}
}

View File

@@ -0,0 +1,139 @@
/**
* Copyright (c) 2010-2020 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.rest;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import java.util.Collections;
import java.util.Dictionary;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.server.ResourceConfig;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.openhab.io.hueemulation.internal.HueEmulationConfig;
import org.openhab.io.hueemulation.internal.dto.HueUnauthorizedConfig;
import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseCreateUser;
import org.openhab.io.hueemulation.internal.rest.mocks.ConfigStoreWithoutMetadata;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
/**
* Tests for various user management API endpoints.
*
* @author David Graeff - Initial contribution
*/
public class UsersAndConfigTests {
ConfigurationAccess configurationAccess = new ConfigurationAccess();
CommonSetup commonSetup;
@Before
public void setUp() throws IOException {
commonSetup = new CommonSetup(false);
configurationAccess.cs = commonSetup.cs;
configurationAccess.userManagement = commonSetup.userManagement;
configurationAccess.configAdmin = commonSetup.configAdmin;
commonSetup.start(new ResourceConfig().registerInstances(configurationAccess));
}
@After
public void tearDown() {
commonSetup.dispose();
}
@Test
public void invalidUser() {
assertFalse(commonSetup.userManagement.authorizeUser("blub"));
}
@Test
public void validUser() {
assertTrue(commonSetup.userManagement.authorizeUser("testuser"));
}
@Test
public void configStoreRestartOnNoUUID() {
ConfigStore configStore = new ConfigStoreWithoutMetadata(commonSetup.networkAddressService,
commonSetup.configAdmin, commonSetup.scheduler);
// No uuid known yet
assertThat(configStore.ds.config.uuid, is(""));
configStore.activate(Collections.emptyMap());
assertThat(configStore.getConfig().uuid, not(is("")));
// The config admin service was requested for the service config
Mockito.verify(commonSetup.configAdminConfig).getProperties();
Dictionary<String, Object> p = commonSetup.configAdminConfig.getProperties();
// And the service config was updated
assertThat(p.get(HueEmulationConfig.CONFIG_UUID), is(configStore.getConfig().uuid));
}
@Test
public void addUser() {
// GET should fail
assertEquals(405, commonSetup.client.target(commonSetup.basePath).request().get().getStatus());
String body = "{'username':'testuser','devicetype':'app#device'}";
Response response;
HueResponse[] r;
// Post should create a user, except: if linkbutton not enabled
response = commonSetup.client.target(commonSetup.basePath).request().post(Entity.json(body));
assertThat(response.getStatus(), is(200));
r = commonSetup.cs.gson.fromJson(response.readEntity(String.class), HueResponse[].class);
assertNotNull(r[0].error);
// Post should create a user
commonSetup.cs.ds.config.linkbutton = true;
response = commonSetup.client.target(commonSetup.basePath).request().post(Entity.json(body));
assertThat(response.getStatus(), is(200));
JsonElement e = new JsonParser().parse(response.readEntity(String.class)).getAsJsonArray().get(0);
e = e.getAsJsonObject().get("success");
HueSuccessResponseCreateUser rc = commonSetup.cs.gson.fromJson(e, HueSuccessResponseCreateUser.class);
assertNotNull(rc);
assertThat(commonSetup.cs.ds.config.whitelist.get(rc.username).name, is("app#device"));
}
@Test
public void UnauthorizedAccessTest() {
// Unauthorized config
Response response;
response = commonSetup.client.target(commonSetup.basePath + "/config").request().get();
assertThat(response.getStatus(), is(200));
HueUnauthorizedConfig config = new Gson().fromJson(response.readEntity(String.class),
HueUnauthorizedConfig.class);
assertThat(config.bridgeid, is(commonSetup.cs.ds.config.bridgeid));
assertThat(config.name, is(commonSetup.cs.ds.config.name));
// Invalid user name
response = commonSetup.client.target(commonSetup.basePath + "/invalid/config").request().get();
assertThat(response.getStatus(), is(403));
assertThat(response.readEntity(String.class), containsString("error"));
}
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2010-2020 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.rest.mocks;
import java.util.concurrent.ScheduledExecutorService;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.io.hueemulation.internal.ConfigStore;
import org.osgi.service.cm.ConfigurationAdmin;
/**
* Usually the metadata registry is used to map item UIDs to integer hue IDs.
* For tests we do not need this extra complexity and just map the item UID to the hue ID.
*
* @author David Graeff - Initial contribution
*/
public class ConfigStoreWithoutMetadata extends ConfigStore {
public ConfigStoreWithoutMetadata(NetworkAddressService networkAddressService, ConfigurationAdmin configAdmin,
ScheduledExecutorService scheduler) {
super(networkAddressService, configAdmin, null, scheduler);
}
@Override
protected void determineHighestAssignedHueID() {
}
@Override
public @NonNull String mapItemUIDtoHueID(@Nullable Item item) {
if (item == null) {
throw new IllegalArgumentException();
}
return item.getUID();
}
}

View File

@@ -0,0 +1,146 @@
/**
* Copyright (c) 2010-2020 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.rest.mocks;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.RegistryHook;
/**
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class DummyItemRegistry implements ItemRegistry {
Map<String, Item> items = new TreeMap<>();
List<RegistryChangeListener<Item>> listeners = new ArrayList<>();
@Override
public void addRegistryChangeListener(RegistryChangeListener<Item> listener) {
listeners.add(listener);
}
@Override
public Collection<Item> getAll() {
return items.values();
}
@Override
public Stream<Item> stream() {
return items.values().stream();
}
@Override
public @Nullable Item get(String key) {
return items.get(key);
}
@Override
public void removeRegistryChangeListener(RegistryChangeListener<Item> listener) {
listeners.remove(listener);
}
@Override
public Item add(Item element) {
Item put = items.put(element.getUID(), element);
for (RegistryChangeListener<Item> l : listeners) {
l.added(element);
}
return put;
}
@Override
public @Nullable Item update(Item element) {
Item put = items.put(element.getUID(), element);
for (RegistryChangeListener<Item> l : listeners) {
l.updated(put, element);
}
return put;
}
@Override
public @Nullable Item remove(String key) {
Item put = items.remove(key);
for (RegistryChangeListener<Item> l : listeners) {
l.removed(put);
}
return put;
}
@Override
public Item getItem(String name) {
return items.get(name);
}
@Override
public Item getItemByPattern(String name) {
return items.get(name);
}
@Override
public Collection<Item> getItems() {
return items.values();
}
@Override
public Collection<Item> getItemsOfType(String type) {
return items.values();
}
@Override
public Collection<Item> getItems(String pattern) {
return items.values();
}
@Override
public Collection<Item> getItemsByTag(String... tags) {
return items.values();
}
@Override
public Collection<Item> getItemsByTagAndType(String type, String... tags) {
return items.values();
}
@SuppressWarnings("unchecked")
@Override
public <T extends Item> Collection<T> getItemsByTag(Class<T> typeFilter, String... tags) {
return (Collection<T>) items.values();
}
@Override
public @Nullable Item remove(String itemName, boolean recursive) {
Item put = items.remove(itemName);
for (RegistryChangeListener<Item> l : listeners) {
l.removed(put);
}
return put;
}
@Override
public void addRegistryHook(RegistryHook<Item> hook) {
}
@Override
public void removeRegistryHook(RegistryHook<Item> hook) {
}
}

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) 2010-2020 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.rest.mocks;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
/**
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class DummyMetadataRegistry implements MetadataRegistry {
Map<MetadataKey, Metadata> items = new HashMap<>();
List<RegistryChangeListener<Metadata>> listeners = new ArrayList<>();
@Override
public void addRegistryChangeListener(RegistryChangeListener<Metadata> listener) {
listeners.add(listener);
}
@Override
public Collection<Metadata> getAll() {
return items.values();
}
@Override
public Stream<Metadata> stream() {
return items.values().stream();
}
@Override
public @Nullable Metadata get(@Nullable MetadataKey key) {
return items.get(key);
}
@Override
public void removeRegistryChangeListener(RegistryChangeListener<Metadata> listener) {
listeners.remove(listener);
}
@Override
public Metadata add(Metadata element) {
Metadata put = items.put(element.getUID(), element);
for (RegistryChangeListener<Metadata> l : listeners) {
l.added(element);
}
return put;
}
@Override
public @Nullable Metadata update(Metadata element) {
Metadata put = items.put(element.getUID(), element);
for (RegistryChangeListener<Metadata> l : listeners) {
l.updated(put, element);
}
return put;
}
@Override
public @Nullable Metadata remove(MetadataKey key) {
Metadata put = items.remove(key);
for (RegistryChangeListener<Metadata> l : listeners) {
l.removed(put);
}
return put;
}
@Override
public boolean isInternalNamespace(String namespace) {
return false;
}
@Override
public void removeItemMetadata(String name) {
}
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) 2010-2020 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.rest.mocks;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.automation.Rule;
import org.openhab.core.automation.RuleRegistry;
/**
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class DummyRuleRegistry implements RuleRegistry {
Map<String, Rule> items = new HashMap<>();
List<RegistryChangeListener<Rule>> listeners = new ArrayList<>();
@Override
public void addRegistryChangeListener(RegistryChangeListener<Rule> listener) {
listeners.add(listener);
}
@Override
public Collection<Rule> getAll() {
return items.values();
}
@NonNullByDefault({})
@Override
public Stream<Rule> stream() {
return items.values().stream();
}
@Override
public @Nullable Rule get(@Nullable String key) {
return items.get(key);
}
@Override
public void removeRegistryChangeListener(RegistryChangeListener<Rule> listener) {
listeners.remove(listener);
}
@Override
public Rule add(Rule element) {
Rule put = items.put(element.getUID(), element);
for (RegistryChangeListener<Rule> l : listeners) {
l.added(element);
}
return put;
}
@Override
public @Nullable Rule update(Rule element) {
Rule put = items.put(element.getUID(), element);
for (RegistryChangeListener<Rule> l : listeners) {
l.updated(put, element);
}
return put;
}
@Override
public @Nullable Rule remove(String key) {
Rule put = items.remove(key);
for (RegistryChangeListener<Rule> l : listeners) {
l.removed(put);
}
return put;
}
@NonNullByDefault({})
@Override
public Collection<Rule> getByTag(String tag) {
return Collections.emptyList();
}
@NonNullByDefault({})
@Override
public Collection<Rule> getByTags(String... tags) {
return Collections.emptyList();
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2010-2020 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.rest.mocks;
import java.util.Collection;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.storage.Storage;
import org.openhab.io.hueemulation.internal.dto.HueUserAuthWithSecrets;
/**
* @author David Graeff - Initial contribution
*/
public class DummyUsersStorage implements Storage<HueUserAuthWithSecrets> {
Map<String, HueUserAuthWithSecrets> users = new TreeMap<>();
public DummyUsersStorage() {
users.put("testuser", new HueUserAuthWithSecrets("appname", "devicename", "testuser", "clientkey"));
}
@Override
public @Nullable HueUserAuthWithSecrets put(String key, @Nullable HueUserAuthWithSecrets value) {
return users.put(key, value);
}
@Override
public @Nullable HueUserAuthWithSecrets remove(String key) {
return users.remove(key);
}
@Override
public boolean containsKey(String key) {
return users.containsKey(key);
}
@Override
public @Nullable HueUserAuthWithSecrets get(String key) {
return users.get(key);
}
@Override
public Collection<@NonNull String> getKeys() {
return users.keySet();
}
@Override
public Collection<@Nullable HueUserAuthWithSecrets> getValues() {
return users.values();
}
}

View File

@@ -0,0 +1,152 @@
/**
* Copyright (c) 2010-2020 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.upnp;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.ws.rs.core.Response;
import org.glassfish.grizzly.osgi.httpservice.HttpServiceImpl;
import org.glassfish.grizzly.osgi.httpservice.OSGiMainHandler;
import org.glassfish.grizzly.osgi.httpservice.util.Logger;
import org.glassfish.jersey.server.ResourceConfig;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.openhab.io.hueemulation.internal.rest.CommonSetup;
import org.openhab.io.hueemulation.internal.rest.LightsAndGroups;
import org.osgi.framework.Bundle;
import org.osgi.util.tracker.ServiceTracker;
/**
* Tests the upnp server part if the description.xml is available and if the udp thread comes online
*
* @author David Graeff - Initial contribution
*/
public class UpnpTests {
protected static CommonSetup commonSetup = null;
protected UpnpServer subject;
protected static OSGiMainHandler mainHttpHandler;
private static HttpServiceImpl httpServiceImpl;
private static String descriptionPath;
LightsAndGroups lightsAndGroups = new LightsAndGroups();
@BeforeClass
public static void setupHttpParts() throws IOException {
commonSetup = new CommonSetup(true);
commonSetup.start(new ResourceConfig());
descriptionPath = commonSetup.basePath.replace("/api", "/description.xml");
Logger logger = new org.glassfish.grizzly.osgi.httpservice.util.Logger(mock(ServiceTracker.class));
mainHttpHandler = new OSGiMainHandler(logger, mock(Bundle.class));
commonSetup.server.getServerConfiguration().addHttpHandler(mainHttpHandler, "/");
httpServiceImpl = new HttpServiceImpl(mock(Bundle.class), logger);
}
@Before
public void setup() {
Executor executor = mock(Executor.class);
Mockito.doAnswer(a -> {
((Runnable) a.getArgument(0)).run();
return null;
}).when(executor).execute(ArgumentMatchers.any());
subject = new UpnpServer(executor);
subject.httpService = httpServiceImpl;
subject.cs = commonSetup.cs;
subject.overwriteReadyToFalse = true;
subject.activate(); // don't execute handleEvent()
subject.overwriteReadyToFalse = false;
}
@After
public void tearDown() {
subject.deactivate();
}
@AfterClass
public static void tearDownHttp() {
mainHttpHandler.unregisterAll();
commonSetup.dispose();
}
@Test
public void descriptionWithoutAddress() {
Response response = commonSetup.client.target(descriptionPath).request().get();
assertEquals(404, response.getStatus());
}
@Test
public void descriptionWithAddress()
throws InterruptedException, ExecutionException, TimeoutException, IOException {
HueEmulationConfigWithRuntime r = subject.createConfiguration(null);
r = subject.performAddressTest(r);
subject.applyConfiguration(r);
Response response = commonSetup.client.target(descriptionPath).request().get();
assertEquals(200, response.getStatus());
String body = response.readEntity(String.class);
assertThat(body, is(subject.xmlDocWithAddress));
if (r == null) {
throw new IllegalStateException();
}
// UDP thread started?
r.startNow(r).get(5, TimeUnit.SECONDS);
assertThat(subject.upnpAnnouncementThreadRunning(), is(true));
// Send M-SEARCH UPNP "packet" and check if the result contains our bridge ID
try (DatagramSocket sendSocket = new DatagramSocket()) {
sendSocket.setSoTimeout(700);
byte[] bytes = "M-SEARCH".getBytes();
sendSocket.send(new DatagramPacket(bytes, bytes.length, subject.MULTI_ADDR_IPV4, UpnpServer.UPNP_PORT));
byte[] buffer = new byte[1000];
DatagramPacket p = new DatagramPacket(buffer, buffer.length);
sendSocket.receive(p);
String received = new String(buffer);
assertThat(received, CoreMatchers.startsWith("HTTP/1.1 200 OK"));
assertThat(received, CoreMatchers.containsString("hue-bridgeid: DEMOUUID"));
}
r.dispose();
assertThat(subject.upnpAnnouncementThreadRunning(), is(false));
}
@Test
public void handEventTest() throws InterruptedException, ExecutionException, TimeoutException {
subject.handleEvent(null);
subject.configChangeFuture.get(5, TimeUnit.SECONDS);
assertThat(subject.upnpAnnouncementThreadRunning(), is(true));
subject.deactivate();
assertThat(subject.upnpAnnouncementThreadRunning(), is(false));
}
}