From 11ec3160aa9156561e691656d08f34f72c69605a Mon Sep 17 00:00:00 2001 From: David Graeff Date: Thu, 28 Mar 2019 08:32:50 +0100 Subject: [PATCH] Use JAX-RS for hue emulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Split RESTapi class into logical units (ConfigurationAccess, UserManagement, LightsAndGroups) * rules support * scenes support * schedule support * Refactor tests: Use jersey JAX-RS server to test without requiring the framework (non OSGi tests) Signed-off-by: David Graeff schedules Signed-off-by: David Gräff --- bundles/org.openhab.io.hueemulation/README.md | 66 ++- bundles/org.openhab.io.hueemulation/pom.xml | 21 + .../io/hueemulation/internal/ConfigStore.java | 231 ++++++++ .../io/hueemulation/internal/DeviceType.java | 4 +- .../internal/HueEmulationConfig.java | 6 + .../internal/HueEmulationService.java | 464 +++------------- .../io/hueemulation/internal/LightItems.java | 363 ------------ .../hueemulation/internal/NetworkUtils.java | 142 +++++ ...figManagement.java => PairingTimeout.java} | 77 +-- .../io/hueemulation/internal/RESTApi.java | 434 --------------- .../io/hueemulation/internal/RuleUtils.java | 372 +++++++++++++ .../{dto/HueDevice.java => StateUtils.java} | 316 ++++++----- ...ulationUpnpServer.java => UpnpServer.java} | 68 ++- .../hueemulation/internal/UserManagement.java | 108 ---- .../io/hueemulation/internal/Utils.java | 72 --- .../AbsoluteDateTimeTriggerHandler.java | 112 ++++ .../automation/HttpActionHandler.java | 99 ++++ .../automation/HueHandlerFactory.java | 71 +++ .../automation/HueRuleConditionHandler.java | 180 ++++++ .../automation/RemoveRuleActionHandler.java | 63 +++ .../automation/RulesHandlerFactory.java | 78 +++ .../TimerModuleExHandlerFactory.java | 74 +++ .../automation/TimerTriggerHandler.java | 126 +++++ .../automation/dto/HueRuleTriggerConfig.java | 29 + .../dto/ItemCommandActionConfig.java | 26 + .../internal/dto/AbstractHueState.java | 4 + .../internal/dto/HueAuthorizedConfig.java | 27 +- .../internal/dto/HueCapability.java | 26 + .../internal/dto/HueDataStore.java | 42 +- .../hueemulation/internal/dto/HueGroup.java | 82 --- .../internal/dto/HueGroupEntry.java | 110 ++++ .../internal/dto/HueLightEntry.java | 195 +++++++ .../internal/dto/HueRuleEntry.java | 140 +++++ .../internal/dto/HueSceneEntry.java | 58 ++ .../internal/dto/HueSceneWithLightstates.java | 46 ++ .../internal/dto/HueScheduleEntry.java | 37 ++ .../internal/dto/HueSensorEntry.java | 125 +++++ .../internal/dto/HueStateBulb.java | 9 + .../internal/dto/HueStateColorBulb.java | 35 +- .../internal/dto/HueStatePlug.java | 6 + .../internal/dto/HueUnauthorizedConfig.java | 2 +- .../internal/dto/HueUserAuth.java | 15 +- .../internal/dto/HueUserAuthWithSecrets.java | 47 ++ .../dto/changerequest/HueChangeRequest.java | 3 +- .../changerequest/HueChangeSceneEntry.java | 37 ++ .../changerequest/HueChangeScheduleEntry.java | 36 ++ .../dto/changerequest/HueCommand.java | 43 ++ .../dto/changerequest/HueCreateUser.java | 5 +- .../{ => changerequest}/HueStateChange.java | 2 +- .../internal/dto/response/HueResponse.java | 9 + ...hts.java => HueResponseSuccessSimple.java} | 12 +- .../dto/response/HueSuccessCreateGroup.java | 6 +- .../dto/response/HueSuccessGeneric.java | 53 ++ .../dto/response/HueSuccessResponse.java | 2 +- .../HueSuccessResponseCreateUser.java | 11 +- .../HueSuccessResponseStateChanged.java | 3 +- .../internal/rest/ConfigurationAccess.java | 148 +++++ .../internal/rest/LightsAndGroups.java | 522 ++++++++++++++++++ .../io/hueemulation/internal/rest/Rules.java | 376 +++++++++++++ .../io/hueemulation/internal/rest/Scenes.java | 423 ++++++++++++++ .../hueemulation/internal/rest/Schedules.java | 343 ++++++++++++ .../hueemulation/internal/rest/Sensors.java | 287 ++++++++++ .../internal/rest/UserManagement.java | 247 +++++++++ .../moduletypes/AbsoluteDateTimeTrigger.json | 36 ++ .../automation/moduletypes/HttpAction.json | 94 ++++ .../moduletypes/HueRuleCondition.json | 33 ++ .../moduletypes/RemoveRuleAction.json | 20 + .../automation/moduletypes/TimerTrigger.json | 36 ++ .../main/resources/ESH-INF/config/config.xml | 5 + .../hueemulation/internal/HueRestAPITest.java | 190 ------- .../hueemulation/internal/LightItemsTest.java | 166 ------ .../internal/rest/CommonSetup.java | 150 +++++ .../rest/ItemUIDtoHueIDMappingTests.java | 117 ++++ .../internal/rest/LightsAndGroupsTests.java | 321 +++++++++++ .../rest/RuleConditionHandlerTests.java | 183 ++++++ .../internal/rest/RulesTests.java | 280 ++++++++++ .../internal/rest/SceneTests.java | 231 ++++++++ .../internal/rest/ScheduleTests.java | 426 ++++++++++++++ .../internal/rest/SensorTests.java | 127 +++++ .../internal/rest/UsersAndConfigTests.java | 147 +++++ .../mocks/ConfigStoreWithoutMetadata.java | 33 ++ .../rest/mocks/DummyItemRegistry.java | 156 ++++++ .../rest/mocks/DummyMetadataRegistry.java | 99 ++++ .../rest/mocks/DummyRuleRegistry.java | 101 ++++ .../rest/mocks/DummyUsersStorage.java | 64 +++ .../internal/HueEmulationServiceOSGiTest.java | 262 +-------- 86 files changed, 8089 insertions(+), 2364 deletions(-) create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigStore.java delete mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/LightItems.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/NetworkUtils.java rename bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/{ConfigManagement.java => PairingTimeout.java} (52%) delete mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RESTApi.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RuleUtils.java rename bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/{dto/HueDevice.java => StateUtils.java} (56%) rename bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/{HueEmulationUpnpServer.java => UpnpServer.java} (80%) delete mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UserManagement.java delete mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/Utils.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/AbsoluteDateTimeTriggerHandler.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HttpActionHandler.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueHandlerFactory.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueRuleConditionHandler.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RemoveRuleActionHandler.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RulesHandlerFactory.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerModuleExHandlerFactory.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerTriggerHandler.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/HueRuleTriggerConfig.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/ItemCommandActionConfig.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueCapability.java delete mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroup.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroupEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueLightEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueRuleEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneWithLightstates.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueScheduleEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSensorEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuthWithSecrets.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeSceneEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeScheduleEntry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCommand.java rename bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/{ => changerequest}/HueStateChange.java (94%) rename bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/{HueSuccessResponseStartSearchLights.java => HueResponseSuccessSimple.java} (66%) create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessGeneric.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/ConfigurationAccess.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroups.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Rules.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Scenes.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Schedules.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Sensors.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/UserManagement.java create mode 100644 bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/AbsoluteDateTimeTrigger.json create mode 100644 bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HttpAction.json create mode 100644 bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HueRuleCondition.json create mode 100644 bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/RemoveRuleAction.json create mode 100644 bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/TimerTrigger.json delete mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/HueRestAPITest.java delete mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/LightItemsTest.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/CommonSetup.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ItemUIDtoHueIDMappingTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroupsTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RuleConditionHandlerTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RulesTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SceneTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ScheduleTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SensorTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/UsersAndConfigTests.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/ConfigStoreWithoutMetadata.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyItemRegistry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyMetadataRegistry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyRuleRegistry.java create mode 100644 bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyUsersStorage.java diff --git a/bundles/org.openhab.io.hueemulation/README.md b/bundles/org.openhab.io.hueemulation/README.md index 301790dcf7ed8..681dc4469da3c 100644 --- a/bundles/org.openhab.io.hueemulation/README.md +++ b/bundles/org.openhab.io.hueemulation/README.md @@ -1,23 +1,36 @@ # openHAB Hue Emulation Service -Hue Emulation exposes openHAB items as Hue devices to other Hue HTTP API compatible applications like an Amazon Echo, Google Home or +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 "huerule" +* Schedule: Maps to rules (new rule engine) that are tagged with "hueschedule" + +You can create / modify and remove groups, rooms, scenes, rules and schedules from within Hue compatible devices and apps. + ## Discovery: -As soon as the binding is enabled, it will announce the presence of an (emulated) HUE bridge of the second generation (square bridge). +As soon as the service is enabled, it will announce the presence of an (emulated) HUE bridge of the second generation (square bridge). Hue bridges are using the Universal Plug and Play (UPnP) protocol for discovery. 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 devices +## Exposed lights It is important to note that you are exposing *Items* not *Things* or *Channels*. -Only Color, Dimmer and Switch type *Items* are supported. +Only Color, Dimmer, Rollershutter and Switch type *Items* are supported. This service can emulate 3 different devices: @@ -31,9 +44,9 @@ The exposed Hue-type depends on some criteria: * 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. -Tags can be configured in Paper UI, please refer to the next section. 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 @@ -45,13 +58,14 @@ You can tag items manually though as well. ## Exposed names -Your items labels are used for exposing! The default naming schema in Paper UI -for automatically linked items unfortunately names *Items* like their Channel 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 Paper UI. +All options are available in the graphical interface. +For textual configuration find the following keys to set. Pairing can be turned on and off: @@ -84,8 +98,10 @@ Usually you do not want to set this option, but change the primary address confi org.openhab.hueemulation:discoveryIp=192.168.1.100 ``` +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 the other criteria. +Can be empty to match an item based on other criterias. ``` org.openhab.hueemulation:restrictToTagsSwitches=Switchable @@ -93,6 +109,17 @@ 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 Zigbee Switches to be exposed +as lights instead (because your Amazon Echo does not support switches for example), +you want to have (this can be set in the graphical interface as well): + +``` +org.openhab.hueemulation:restrictToTagsSwitches=NONE +org.openhab.hueemulation:restrictToTagsWhiteLights=Lighting,Switchable +org.openhab.hueemulation:restrictToTagsColorLights=ColorLighting +``` + ## Troubleshooting Some devices like the Amazon Echo, Google Home and all Philips devices expect a Hue bridge to @@ -104,21 +131,22 @@ You can test if the hue emulation does its job by enabling pairing mode includin 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/testuser/lights?debug=true" +2. Navigate with your browser to "http://your-openhab-ip/api/testuser/lights" to check all exposed lights and switches. + You can use the openHAB "REST Doc" extension for better formatting of the result. -## Text configuration example +Depending on the firmware version of your Amazon Echo, it may not support coloured bulbs or switches. +Please assign "ColorLighting" and "Switchable" to the `WhiteLights` type as explained above. -The item label will be used as the Hue device name. +Also note that Amazon Echos are stubborn as. You might need to remove all former recognised devices multiple +times and perform the search via different Echos and also the web or mobile application. -``` -Switch TestSwitch "Kitchen Switch" [ "Switchable" ] -Color TestColorBulb "Bathroom" [ "ColorLighting" ] -Dimmer TestDimmer "Hallway" [ "Lighting" ] -``` +## Text configuration example -If you have an item with a channel or binding attached the tag needs to be applied before the channel. +The item label will be used as the Hue device name. ``` -Switch BLamp1 "Bedroom Lamp 1" (FirstFloor) [ "Lighting" ] {mqtt=">[mosquitto:cmnd/sonoff-BLamp1/POWER:command:*:default],<[mosquitto:stat/sonoff-BLamp1/POWER:state:default]"} +Switch TestSwitch "Kitchen Switch" [ "Switchable" ] {channel="..."} +Color TestColorBulb "Bathroom" [ "ColorLighting" ] {channel="..."} +Dimmer TestDimmer "Hallway" [ "Lighting" ] {channel="..."} ``` diff --git a/bundles/org.openhab.io.hueemulation/pom.xml b/bundles/org.openhab.io.hueemulation/pom.xml index 4a45018650ea4..5da7e779fadc5 100644 --- a/bundles/org.openhab.io.hueemulation/pom.xml +++ b/bundles/org.openhab.io.hueemulation/pom.xml @@ -13,4 +13,25 @@ openHAB Add-ons :: Bundles :: Hue Emulation Service + + + + org.glassfish.grizzly + grizzly-http-server + 2.4.4 + test + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + 2.28 + test + + + org.glassfish.jersey.inject + jersey-hk2 + 2.28 + test + + diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigStore.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigStore.java new file mode 100644 index 0000000000000..a8ecceabb9469 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigStore.java @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2010-2019 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.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.Dictionary; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.items.Metadata; +import org.eclipse.smarthome.core.items.MetadataKey; +import org.eclipse.smarthome.core.items.MetadataRegistry; +import org.eclipse.smarthome.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.Reference; +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. + *

+ * 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 +@NonNullByDefault +public class ConfigStore { + public static final String METAKEY = "HUEEMU"; + + private final Logger logger = LoggerFactory.getLogger(ConfigStore.class); + + public HueDataStore ds = new HueDataStore(); + + /** + * 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; + + //// objects, set within activate() + protected @NonNullByDefault({}) InetAddress address; + protected @NonNullByDefault({}) HueEmulationConfig config; + + public Set switchFilter = Collections.emptySet(); + public Set colorFilter = Collections.emptySet(); + public Set whiteFilter = Collections.emptySet(); + + private int highestAssignedHueID = 1; + + ConfigStore() { + } + + /** + * 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) { + this.networkAddressService = networkAddressService; + this.configAdmin = configAdmin; + this.metadataRegistry = metadataRegistry; + + } + + @Activate + public void activate(Map properties) { + this.config = new Configuration(properties).as(HueEmulationConfig.class); + + if (config.discoveryHttpPort == 0) { + config.discoveryHttpPort = Integer.getInteger("org.osgi.service.http.port", 8080); + } + + String discoveryIp = config.discoveryIp; + if (discoveryIp == null) { + discoveryIp = networkAddressService.getPrimaryIpv4HostAddress(); + } + + if (discoveryIp == null) { + logger.warn("No primary IP address configured. Discovery disabled!"); + return; + } + + try { + address = InetAddress.getByName(discoveryIp); + } catch (UnknownHostException e) { + logger.warn("No primary IP address configured. Discovery disabled!", e); + address = InetAddress.getLoopbackAddress(); + return; + } + + // Get and apply configurations + ds.config.linkbutton = config.pairingEnabled; + ds.config.createNewUserOnEveryEndpoint = config.createNewUserOnEveryEndpoint; + ds.config.networkopenduration = config.pairingTimeout; + ds.config.mac = NetworkUtils.getMAC(address); + ds.config.ipaddress = address.getHostAddress(); + ds.config.devicename = config.devicename; + if (config.uuid.isEmpty()) { + config.uuid = UUID.randomUUID().toString(); + org.osgi.service.cm.Configuration configuration; + try { + configuration = configAdmin.getConfiguration(HueEmulationService.CONFIG_PID); + Dictionary dictionary = configuration.getProperties(); + dictionary.put(HueEmulationConfig.CONFIG_UUID, config.uuid); + configuration.update(dictionary); // This will restart the service (and call activate again) + return; + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } + + 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); + } + + determineHighestAssignedHueID(); + } + + 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(@Nullable Item item) { + if (item == null) { + throw new IllegalArgumentException(); + } + + 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.update(new Metadata(key, String.valueOf(hueId), null)); + } + + return String.valueOf(hueId); + } + + public InetAddress getAddress() { + return address; + } + + public HueEmulationConfig getConfig() { + return config; + } + + public int getHighestAssignedHueID() { + return highestAssignedHueID; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/DeviceType.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/DeviceType.java index b0b603835d589..57e2e5b2c3e57 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/DeviceType.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/DeviceType.java @@ -13,7 +13,9 @@ package org.openhab.io.hueemulation.internal; /** - * Device type + * 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 */ diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationConfig.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationConfig.java index e328270fc6bb8..bf05b565359b4 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationConfig.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationConfig.java @@ -28,6 +28,7 @@ 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 @@ -35,6 +36,7 @@ public class HueEmulationConfig { */ public boolean createNewUserOnEveryEndpoint = true; public static final String CONFIG_CREATE_NEW_USER_ON_THE_FLY = "createNewUserOnEveryEndpoint"; + /** Pairing timeout in seconds */ public int pairingTimeout = 60; public @Nullable String discoveryIp; @@ -46,6 +48,10 @@ public class HueEmulationConfig { /** Comma separated list of tags */ public String restrictToTagsWhiteLights = "Lighting"; + public static final String CONFIG_UUID = "uuid"; + public String uuid = ""; + public String devicename = "openHAB"; + public Set switchTags() { return Stream.of(restrictToTagsSwitches.split(",")).map(String::trim).collect(Collectors.toSet()); } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationService.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationService.java index c04100197adcd..8fb7e4e08f730 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationService.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationService.java @@ -16,431 +16,149 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.io.Writer; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Collections; -import java.util.Hashtable; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; 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.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +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.smarthome.config.core.ConfigurableService; -import org.eclipse.smarthome.config.core.Configuration; -import org.eclipse.smarthome.core.events.EventPublisher; -import org.eclipse.smarthome.core.items.ItemRegistry; -import org.eclipse.smarthome.core.net.NetworkAddressService; -import org.eclipse.smarthome.core.service.ReadyMarker; -import org.eclipse.smarthome.core.service.ReadyService; -import org.eclipse.smarthome.core.service.ReadyService.ReadyTracker; -import org.eclipse.smarthome.core.storage.StorageService; -import org.openhab.io.hueemulation.internal.RESTApi.HttpMethod; -import org.openhab.io.hueemulation.internal.dto.HueDataStore; -import org.openhab.io.hueemulation.internal.dto.HueGroup; +import org.eclipse.smarthome.io.rest.RESTResource; 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; -import org.osgi.service.cm.ConfigurationAdmin; +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.UserManagement; 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.ReferencePolicy; -import org.osgi.service.http.HttpContext; -import org.osgi.service.http.HttpService; -import org.osgi.service.http.NamespaceException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonWriter; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; /** - * Emulates a Hue compatible HTTP API server. Provides /description.xml for upnp (see {@link HueEmulationUpnpServer} - * and /api for a hue compatible REST API. + * Provides a Hue compatible HTTP REST API including /description.xml for upnp (see {@link UpnpServer} + * and the /api endpoint. * *

    + *
  • The hue device ID is managed by the {@link ConfigStore}. *
  • Find the user management (/api/{username}/config/whitelist) in {@link UserManagement}. - *
  • The unique device ID is managed by the {@link ConfigManagement}. A user is able to rename his brigde via the API. - *
  • ESH items via the {@link ItemRegistry} are mapped to /api/{username}/lights and /api/{username}/groups in - * {@link LightItems}. - *
  • The REST processing is done in {@link RESTApi}. + *
  • openHAB items via the {@link org.eclipse.smarthome.core.items.ItemRegistry} are mapped to /api/{username}/lights + * and /api/{username}/groups in {@link LightsAndGroups}. + *
  • Have a look for all other functionality in the rest package. *
* - * @author Dan Cunningham - Initial Contribution - * @author Kai Kreuzer - Improved resource handling to avoid leaks - * @author David Graeff - Rewritten - * + * @author David Graeff - Initial Contribution */ -@SuppressWarnings("serial") @NonNullByDefault @Component(immediate = true, service = { - HueEmulationService.class }, configurationPid = "org.openhab.hueemulation", property = { + HueEmulationService.class }, configurationPid = HueEmulationService.CONFIG_PID, property = { org.osgi.framework.Constants.SERVICE_PID + "=org.openhab.hueemulation", ConfigurableService.SERVICE_PROPERTY_DESCRIPTION_URI + "=io:hueemulation", ConfigurableService.SERVICE_PROPERTY_CATEGORY + "=io", ConfigurableService.SERVICE_PROPERTY_LABEL + "=Hue Emulation" }) -public class HueEmulationService implements ReadyTracker { - - private final Path DISCOVERY_PATH = Paths.get(RESTApi.PATH + "/description.xml"); - private final Path DISCOVERY_PATH_ROOT = Paths.get("/description.xml"); +public class HueEmulationService implements RESTResource { + public static final String CONFIG_PID = "org.openhab.hueemulation"; + public static final String RESTAPI_PATH = "/api"; private final Logger logger = LoggerFactory.getLogger(HueEmulationService.class); - private final Gson gson = new GsonBuilder() - .registerTypeAdapter(HueSuccessResponseStateChanged.class, new HueSuccessResponseStateChanged.Serializer()) - .registerTypeAdapter(HueGroup.class, new HueGroup.Serializer()).create(); //// Required services //// - private @NonNullByDefault({}) HttpService httpService; - private @NonNullByDefault({}) NetworkAddressService networkAddressService; - private @NonNullByDefault({}) ReadyService readyService; - protected @NonNullByDefault({}) HueEmulationUpnpServer discovery; + @Reference + protected @NonNullByDefault({}) ConfigStore cs; + @Reference + protected @NonNullByDefault({}) UpnpServer discovery; + @Reference + protected @NonNullByDefault({}) UserManagement userManagement; + @Reference + protected @NonNullByDefault({}) LightsAndGroups lightItems; + @Reference + protected @NonNullByDefault({}) ConfigurationAccess configurationAccess; + @Reference + protected @NonNullByDefault({}) Scenes scenesAndRules; + @Reference + protected @NonNullByDefault({}) Schedules schedules; + @Reference + protected @NonNullByDefault({}) Rules rules; + @Reference + protected @NonNullByDefault({}) Sensors sensors; //// objects, set within activate() - private @NonNullByDefault({}) HueEmulationConfig config; private @NonNullByDefault({}) String xmlDoc; - protected final HueDataStore ds = new HueDataStore(); - protected final UserManagement userManagement = new UserManagement(ds); - protected final LightItems lightItems = new LightItems(ds); - protected final ConfigManagement configManagement = new ConfigManagement(ds); - protected final RESTApi restAPI = new RESTApi(ds, userManagement, configManagement, gson); - protected boolean started = false; - - /** - * A servlet for providing /api/discovery.xml and the REST API - */ - HttpServlet restAPIservlet = new HttpServlet() { - @NonNullByDefault({}) - @Override - protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - Utils.setHeaders(resp); - final Path path = Paths.get(req.getRequestURI()); - final boolean isDebug = "debug=true".equals(req.getQueryString()); - String postBody; - final HttpMethod method; - - try (PrintWriter httpOut = resp.getWriter()) { - // UPNP discovery document - if (path.equals(DISCOVERY_PATH)) { - sendDiscoveryXML(resp, httpOut); - return; - } - - StringWriter out = new StringWriter(); - - if (!isDebug) { - resp.setContentType("application/json"); - } else { - resp.setContentType("text/plain"); - } - - try { - method = Enum.valueOf(HttpMethod.class, req.getMethod()); - } catch (IllegalArgumentException e) { - resp.setStatus(405); - apiServerError(req, out, HueResponse.METHOD_NOT_ALLOWED, - req.getMethod() + " not allowed for this resource"); - httpOut.print(out.toString()); - return; - } - - if (method == HttpMethod.POST || method == HttpMethod.PUT) { - try { - postBody = req.getReader().lines().collect(Collectors.joining(System.lineSeparator())); - } catch (IllegalStateException e) { - apiServerError(req, out, HueResponse.INTERNAL_ERROR, - "Could not read http body. Jetty failure."); - resp.setStatus(500); - return; - } - } else { - postBody = ""; - } - - int statuscode = 0; - try { - statuscode = restAPI.handle(method, postBody, out, path, isDebug); - switch (statuscode) { - case 10403: // Fake status code -> translate to real one - statuscode = 403; - apiServerError(req, out, HueResponse.LINK_BUTTON_NOT_PRESSED, "link button not pressed"); - break; - case 403: - logger.debug("Unauthorized access to {} from {}:{}!\n", req.getRequestURI(), - req.getRemoteAddr(), req.getRemotePort()); - apiServerError(req, out, HueResponse.UNAUTHORIZED, "Not Authorized"); - break; - case 404: - apiServerError(req, out, HueResponse.NOT_AVAILABLE, "Hue resource not available"); - break; - case 405: - apiServerError(req, out, HueResponse.METHOD_NOT_ALLOWED, - req.getMethod() + " not allowed for this resource"); - break; - } - } catch (JsonParseException e) { - statuscode = 400; - apiServerError(req, out, HueResponse.INVALID_JSON, "Invalid request: " + e.getMessage()); - } - - resp.setStatus(statuscode); - httpOut.print(out.toString()); - - } - } - }; - - /** - * A second servlet for providing /discovery.xml next to /api/discovery.xml - */ - HttpServlet discoveryXMLservlet = new HttpServlet() { - @NonNullByDefault({}) - @Override - protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - final Path path = Paths.get(req.getRequestURI()); - try (PrintWriter out = resp.getWriter()) { - // UPNP discovery document - if (path.equals(DISCOVERY_PATH) || path.equals(DISCOVERY_PATH_ROOT)) { - sendDiscoveryXML(resp, out); - return; - } - } - resp.setStatus(404); - } - }; + @GET + @Path("discovery.xml") + @Produces(MediaType.APPLICATION_XML) + @ApiOperation(value = "Return the Hue UPnP discovery xml") + @ApiResponses(value = { @ApiResponse(code = 200, message = "OK") }) + public Response getDiscovery() { + String address = cs.getAddress().getHostAddress(); + String result = String.format(xmlDoc, address, cs.getConfig().discoveryHttpPort, address, cs.ds.config.bridgeid, + cs.ds.config.uuid); + return NetworkUtils.ResponseWithCors(Response.ok(result)).build(); + } + + @GET + @Path("api/discovery.xml") + @Produces(MediaType.APPLICATION_XML) + @ApiOperation(value = "Return the Hue UPnP discovery xml") + @ApiResponses(value = { @ApiResponse(code = 200, message = "OK") }) + public Response getDiscoveryApi() { + String address = cs.getAddress().getHostAddress(); + String result = String.format(xmlDoc, address, cs.getConfig().discoveryHttpPort, address, cs.ds.config.bridgeid, + cs.ds.config.uuid); + return NetworkUtils.ResponseWithCors(Response.ok(result)).build(); + } + + @GET + @PUT + @POST + @Path("api/{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>() { + }.getType()); + logger.debug("Invalid access on {}", uri); + return Response.status(404).entity(str).build(); + } @Activate - protected void activate(Map properties) { + protected void activate(Map properties) throws IOException { InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("discovery.xml"); if (resourceAsStream == null) { logger.warn("Could not start Hue Emulation service: discovery.xml not found"); return; } - xmlDoc = new BufferedReader(new InputStreamReader(resourceAsStream, StandardCharsets.UTF_8)).lines() - .collect(Collectors.joining("\n")); - - started = false; - modified(properties); - - // The hue emulation need to start very late in the start up process. The - // ready marker service is used to make sure all items and item descriptions are loaded. - readyService.registerTracker(this); - } - - @Modified - protected void modified(Map properties) { - // Get and apply configurations - this.config = new Configuration(properties).as(HueEmulationConfig.class); - lightItems.setFilterTags(config.switchTags(), config.colorTags(), config.whiteTags()); - ds.config.linkbutton = config.pairingEnabled; - ds.config.createNewUserOnEveryEndpoint = config.createNewUserOnEveryEndpoint; - ds.config.networkopenduration = config.pairingTimeout; - - // If started: restart all parts of this service that depend on configuration - if (!started) { - return; + try (InputStreamReader r = new InputStreamReader(resourceAsStream, StandardCharsets.UTF_8); + BufferedReader br = new BufferedReader(r)) { + xmlDoc = br.lines().collect(Collectors.joining("\n")); } - - restartDiscovery(); - configManagement.checkPairingTimeout(); } @Deactivate protected void deactivate() { - readyService.unregisterTracker(this); - configManagement.stopPairingTimeoutThread(); - lightItems.close(); - userManagement.writeToFile(); - configManagement.writeToFile(); - - try { - httpService.unregister(RESTApi.PATH); - httpService.unregister("/description.xml"); - } catch (IllegalArgumentException ignored) { - } - if (discovery != null) { - discovery.shutdown(); - } - } - - @Override - public void onReadyMarkerRemoved(ReadyMarker readyMarker) { - } - - @Override - public synchronized void onReadyMarkerAdded(ReadyMarker readyMarker) { - if (started || !"org.eclipse.smarthome.model.core".equals(readyMarker.getIdentifier())) { - return; - } - - started = true; - HttpContext httpContext = httpService.createDefaultHttpContext(); - try { - httpService.registerServlet(RESTApi.PATH, restAPIservlet, new Hashtable(), httpContext); - } catch (ServletException | NamespaceException e) { - logger.warn("Could not start Hue Emulation service: {}", e.getMessage(), e); - return; - } - - try { // This may fail, but it is not essential for the emulation (just for non-standard devices) - httpService.registerServlet("/description.xml", discoveryXMLservlet, new Hashtable(), - httpContext); - } catch (ServletException | NamespaceException e) { - logger.debug("Hue Emulation: Cannot register /description.xml"); - } - - configManagement.checkPairingTimeout(); - restartDiscovery(); - - // Announce that this service is ready and unregister from the tracker - readyService.markReady(new ReadyMarker("Online", "org.openhab.hueemulation")); - CompletableFuture.runAsync(() -> readyService.unregisterTracker(this)); - } - - @Reference - protected void setStateDescriptionService(ReadyService readyService) { - this.readyService = readyService; - } - - protected void unsetStateDescriptionService(ReadyService readyService) { - this.readyService = null; - } - - @Reference - protected void setConfigurationAdmin(ConfigurationAdmin configAdmin) { - configManagement.setConfigAdmin(configAdmin); - } - - protected void unsetConfigurationAdmin(ConfigurationAdmin configAdmin) { - configManagement.setConfigAdmin(null); - } - - @Reference - protected void setNetworkAddressService(NetworkAddressService netUtils) { - this.networkAddressService = netUtils; - } - - protected void unsetNetworkAddressService(NetworkAddressService netUtils) { - this.networkAddressService = null; - } - - @Reference - protected void setItemRegistry(ItemRegistry itemRegistry) { - lightItems.setItemRegistry(itemRegistry); - } - - protected void unsetItemRegistry(ItemRegistry itemRegistry) { - lightItems.setItemRegistry(null); - } - - @Reference - protected void setEventPublisher(EventPublisher eventPublisher) { - restAPI.setEventPublisher(eventPublisher); - } - - protected void unsetEventPublisher(EventPublisher eventPublisher) { - restAPI.setEventPublisher(null); - } - - @Reference - protected void setHttpService(HttpService httpService) { - this.httpService = httpService; - } - - protected void unsetHttpService(HttpService httpService) { - this.httpService = null; - } - - @Reference(policy = ReferencePolicy.DYNAMIC) - protected void setStorageService(StorageService storageService) { - ClassLoader loader = this.getClass().getClassLoader(); - userManagement.loadUsersFromFile(storageService.getStorage("hue.emulation.users", loader)); - lightItems.loadMappingFromFile(storageService.getStorage("hue.emulation.lights", loader)); - configManagement.loadConfigFromFile(storageService.getStorage("hue.emulation.config", loader)); - } - - protected void unsetStorageService(StorageService storageService) { - userManagement.resetStorage(); - lightItems.resetStorage(); - configManagement.resetStorage(); - } - - void restartDiscovery() { - if (discovery != null) { - discovery.shutdown(); - discovery = null; - } - - if (config.discoveryHttpPort == 0) { - config.discoveryHttpPort = Integer.getInteger("org.osgi.service.http.port"); - } - - String discoveryIp = config.discoveryIp; - if (discoveryIp == null) { - discoveryIp = networkAddressService.getPrimaryIpv4HostAddress(); - } - - if (discoveryIp == null) { - logger.warn("No primary IP address configured. Discovery disabled!"); - return; - } - - InetAddress address; - try { - address = InetAddress.getByName(discoveryIp); - } catch (UnknownHostException e) { - logger.warn("No primary IP address configured. Discovery disabled!", e); - return; - } - - discovery = new HueEmulationUpnpServer(RESTApi.PATH + "/description.xml", ds.config, address, - config.discoveryHttpPort); - discovery.start(); - - ds.config.mac = Utils.getMAC(address); - ds.config.ipaddress = address.getHostAddress(); - } - - private void sendDiscoveryXML(HttpServletResponse resp, PrintWriter out) throws IOException { - resp.setContentType("application/xml"); - String address = discovery.getAddress().getHostAddress(); - out.write( - String.format(xmlDoc, address, config.discoveryHttpPort, address, ds.config.bridgeid, ds.config.uuid)); - } - - /** - * Hue API error response - */ - public void apiServerError(HttpServletRequest req, Writer out, int error, String description) throws IOException { - if (error == HueResponse.UNAUTHORIZED) { - - } else { - logger.debug("'{}' for: {}\nRequest from: {}:{}\n", description, req.getRequestURI(), req.getRemoteAddr(), - req.getRemotePort()); - } - - try (JsonWriter writer = new JsonWriter(out)) { - HueResponse e = new HueResponse( - new HueErrorMessage(error, req.getRequestURI().replace("/api", ""), description)); - gson.toJson(Collections.singleton(e), new TypeToken>() { - }.getType(), writer); - } } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/LightItems.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/LightItems.java deleted file mode 100644 index 4e1ca4cc37910..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/LightItems.java +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Copyright (c) 2010-2019 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.Collections; -import java.util.Set; -import java.util.TreeMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.smarthome.core.common.registry.RegistryChangeListener; -import org.eclipse.smarthome.core.items.GroupItem; -import org.eclipse.smarthome.core.items.Item; -import org.eclipse.smarthome.core.items.ItemRegistry; -import org.eclipse.smarthome.core.library.CoreItemFactory; -import org.eclipse.smarthome.core.storage.Storage; -import org.openhab.io.hueemulation.internal.dto.HueDataStore; -import org.openhab.io.hueemulation.internal.dto.HueDevice; -import org.openhab.io.hueemulation.internal.dto.HueGroup; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Listens to the ItemRegistry for items that fulfill one of these criteria: - *
    - *
  • Type is any of SWITCH, DIMMER, COLOR, or Group - *
  • The category is "ColorLight" for coloured lights or "Light" for switchables. - *
  • The item is tagged, according to what is set with {@link #setFilterTags(Set, Set, Set)}. - *
- * - *

- * A {@link HueDevice} instances is created for each found item. - * Those are kept in the given {@link HueDataStore}. - *

- * - *

- * Implementing scenes should be done here as well. - *

- * - *

- * The HUE Rest API requires a unique integer ID for every listed device. A storage service - * is used to store and load this mapping. A storage is not required for this class to work, - * but without it the mapping will be temporary only and ids may change on every boot up. - *

- * - *

- *

- * - * @author David Graeff - Initial contribution - * @author Florian Schmidt - Removed base type restriction from Group items - */ -@NonNullByDefault -public class LightItems implements RegistryChangeListener { - private final Logger logger = LoggerFactory.getLogger(LightItems.class); - private static final String ITEM_TYPE_GROUP = "Group"; - private static final Set ALLOWED_ITEM_TYPES = Stream - .of(CoreItemFactory.COLOR, CoreItemFactory.DIMMER, CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH, ITEM_TYPE_GROUP) - .collect(Collectors.toSet()); - - // deviceMap maps a unique Item id to a Hue numeric id - final TreeMap itemUIDtoHueID = new TreeMap<>(); - private final HueDataStore dataStore; - private Set switchFilter = Collections.emptySet(); - private Set colorFilter = Collections.emptySet(); - private Set whiteFilter = Collections.emptySet(); - private @Nullable Storage storage; - private boolean initDone = false; - private @NonNullByDefault({}) ItemRegistry itemRegistry; - - public LightItems(HueDataStore ds) { - dataStore = ds; - } - - /** - * Set filter tags. Empty sets are allowed, items will not be filtered by tags but other criteria then. - * - *

- * Calling this method will reset the {@link HueDataStore} and parse items from the item registry again. - *

- * - * @param switchFilter The switch filter tags - * @param colorFilter The color filter tags - * @param whiteFilter The white bulb filter tags - */ - public void setFilterTags(Set switchFilter, Set colorFilter, Set whiteFilter) { - this.switchFilter = switchFilter; - this.colorFilter = colorFilter; - this.whiteFilter = whiteFilter; - fetchItems(); - } - - /** - * Sets the item registry. Used to load up items and register to changes - * - * @param itemRegistry The item registry - */ - public void setItemRegistry(@Nullable ItemRegistry itemRegistry) { - this.itemRegistry = itemRegistry; - } - - /** - * Load the {@link #itemUIDtoHueID} mapping from the given storage. - * This method can also be called when the storage service changes. - * A changed storage causes an immediately write request. - *

- * Because storage services are dynamically bound and may appear late, - * it may happen that the mapping is only loaded after items have been parsed already. - * In that case the {@link HueDataStore} is reset and items from the item registry are parsed again. - * It is important to keep the once exposed ids though. - *

- * - * @param storage A storage service - */ - public void loadMappingFromFile(@Nullable Storage storage) { - boolean storageChanged = this.storage != null && this.storage != storage; - this.storage = storage; - if (storage == null) { - return; - } - - for (String itemUID : storage.getKeys()) { - Integer hueID = storage.get(itemUID); - if (hueID == null) { - continue; - } - itemUIDtoHueID.put(itemUID, hueID); - } - - if (storageChanged) { - writeToFile(); - } else if (initDone) { // storage comes late to the game -> reassign all items - fetchItems(); - } - } - - /** - * 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)}. - */ - public synchronized void fetchItems() { - initDone = false; - - dataStore.resetGroupsAndLights(); - - itemRegistry.removeRegistryChangeListener(this); - itemRegistry.addRegistryChangeListener(this); - - boolean changed = false; - for (Item item : itemRegistry.getItems()) { - changed |= addItem(item); - } - initDone = true; - - logger.debug("Added items: {}", - dataStore.lights.values().stream().map(l -> l.name).collect(Collectors.joining(", "))); - if (changed) { - writeToFile(); - } - } - - /** - * Saves the ID->Item association to the storage. - */ - private void writeToFile() { - Storage storage = this.storage; - if (storage == null) { - return; - } - storage.getKeys().forEach(key -> storage.remove(key)); - itemUIDtoHueID.forEach((itemUID, hueID) -> storage.put(itemUID, hueID)); - } - - public void resetStorage() { - this.storage = null; - } - - /** - * Unregisters from the {@link ItemRegistry}. - */ - public void close() { - writeToFile(); - itemRegistry.removeRegistryChangeListener(this); - } - - private @Nullable DeviceType determineTargetType(@Nullable String category, String type, Set tags) { - // Determine type, heuristically - DeviceType t = 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 (switchFilter.stream().anyMatch(tags::contains)) { - t = DeviceType.SwitchType; - } - if (whiteFilter.stream().anyMatch(tags::contains)) { - t = DeviceType.WhiteTemperatureType; - } - if (colorFilter.stream().anyMatch(tags::contains)) { - t = DeviceType.ColorType; - } - - // Last but not least, the item type - if (t == null) { - switch (type) { - case CoreItemFactory.COLOR: - if (colorFilter.size() == 0) { - t = DeviceType.ColorType; - } - break; - case CoreItemFactory.DIMMER: - if (whiteFilter.size() == 0) { - t = DeviceType.WhiteTemperatureType; - } - break; - case CoreItemFactory.SWITCH: - if (switchFilter.size() == 0) { - t = DeviceType.SwitchType; - } - break; - } - } - return t; - } - - @Override - public synchronized void added(Item element) { - addItem(element); - } - - String getType(Item element) { - if (element instanceof GroupItem) { - return ITEM_TYPE_GROUP; - } - return element.getType(); - } - - @SuppressWarnings({ "unused", "null" }) - public boolean addItem(Item element) { - // Only allowed types - String type = getType(element); - - if (!ALLOWED_ITEM_TYPES.contains(type)) { - return false; - } - - DeviceType t = determineTargetType(element.getCategory(), type, element.getTags()); - if (t == null) { - return false; - } - - Integer hueID = itemUIDtoHueID.get(element.getUID()); - - boolean itemAssociationCreated = false; - if (hueID == null) { - hueID = dataStore.generateNextLightHueID(); - itemAssociationCreated = true; - } - - HueDevice device = new HueDevice(element, dataStore.config.uuid + "-" + hueID.toString(), t); - device.item = element; - dataStore.lights.put(hueID, device); - if (element instanceof GroupItem) { - GroupItem g = (GroupItem) element; - g.getMembers(); - HueGroup group = new HueGroup(g.getName(), g, itemUIDtoHueID); - dataStore.groups.put(hueID, group); - } - updateGroup0(); - itemUIDtoHueID.put(element.getUID(), hueID); - if (initDone) { - logger.debug("Add item {}", element.getUID()); - if (itemAssociationCreated) { - writeToFile(); - } - } - return itemAssociationCreated; - } - - /** - * The HUE API enforces a Group 0 that contains all lights. - */ - private void updateGroup0() { - dataStore.groups.get(0).lights = dataStore.lights.keySet().stream().map(v -> String.valueOf(v)) - .collect(Collectors.toList()); - } - - @SuppressWarnings({ "null", "unused" }) - @Override - public synchronized void removed(Item element) { - Integer hueID = itemUIDtoHueID.get(element.getUID()); - if (hueID == null) { - return; - } - logger.debug("Remove item {}", element.getUID()); - dataStore.lights.remove(hueID); - dataStore.groups.remove(hueID); - updateGroup0(); - itemUIDtoHueID.remove(element.getUID()); - writeToFile(); - } - - /** - * The tags might have changed - */ - @SuppressWarnings({ "null", "unused" }) - @Override - public synchronized void updated(Item oldElement, Item element) { - Integer hueID = itemUIDtoHueID.get(element.getUID()); - if (hueID == null) { - // If the correct tags got added -> use the logic within added() - added(element); - return; - } - - HueGroup hueGroup = dataStore.groups.get(hueID); - if (hueGroup != null) { - if (element instanceof GroupItem) { - hueGroup.updateItem((GroupItem) element); - } else { - dataStore.groups.remove(hueID); - } - } - - HueDevice hueDevice = dataStore.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 = determineTargetType(element.getCategory(), getType(element), element.getTags()); - if (t == null) { - removed(element); - return; - } - - hueDevice.updateItem(element); - } -} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/NetworkUtils.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/NetworkUtils.java new file mode 100644 index 0000000000000..ffb46e93c6fb0 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/NetworkUtils.java @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2010-2019 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>() { + }.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 responses = new ArrayList<>(); + responses.add(new HueResponse(new HueSuccessGeneric(message, uriPart))); + return Response.ok(gson.toJson(responses, new TypeToken>() { + }.getType())).build(); + } + + public static Response singleSuccess(Gson gson, String message) { + List responses = new ArrayList<>(); + responses.add(new HueResponseSuccessSimple(message)); + return Response.ok(gson.toJson(responses, new TypeToken>() { + }.getType())).build(); + } + + public static Response successList(Gson gson, List successList) { + List responses = new ArrayList<>(); + for (HueSuccessGeneric s : successList) { + if (s.isValid()) { + responses.add(new HueResponse(s)); + } + } + return Response.ok(gson.toJson(responses, new TypeToken>() { + }.getType())).build(); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigManagement.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/PairingTimeout.java similarity index 52% rename from bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigManagement.java rename to bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/PairingTimeout.java index 7180c82c84bd1..2fe1d99c34e64 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/ConfigManagement.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/PairingTimeout.java @@ -14,93 +14,42 @@ import java.io.IOException; import java.util.Dictionary; -import java.util.UUID; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.smarthome.core.storage.Storage; import org.openhab.io.hueemulation.internal.dto.HueDataStore; import org.osgi.service.cm.ConfigurationAdmin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Manages config values of this emulated HUE bridge. + * Manages the pairing timeout. The service is restarted after a pairing timeout, due to the ConfigAdmin + * configuration change. + *

+ * Implementation detail: As soon as the pairing timeout has happened, the OSGi service configuration + * is changed via the configuration admin. This will restart the service. * * @author David Graeff - Initial contribution */ @NonNullByDefault -public class ConfigManagement { - private final Logger logger = LoggerFactory.getLogger(ConfigManagement.class); - private final HueDataStore dataStore; - private @Nullable Storage storage; +public class PairingTimeout { + private final Logger logger = LoggerFactory.getLogger(PairingTimeout.class); private @Nullable Thread pairingTimeoutThread; - private @NonNullByDefault({}) ConfigurationAdmin configAdmin; - - public ConfigManagement(HueDataStore ds) { - dataStore = ds; - } - - public void setConfigAdmin(@Nullable ConfigurationAdmin configAdmin) { - this.configAdmin = configAdmin; - } - - /** - * Load modified config from disk - */ - public void loadConfigFromFile(Storage storage) { - boolean storageChanged = this.storage != null && this.storage != storage; - this.storage = storage; - String devicename = storage.get("devicename"); - if (devicename == null) { - devicename = dataStore.config.devicename; - } - dataStore.config.devicename = devicename; - - String udnString = storage.get("udn"); - if (udnString == null) { - udnString = UUID.randomUUID().toString(); - storage.put("udn", udnString); - } - - dataStore.config.uuid = udnString; - dataStore.config.bridgeid = dataStore.config.uuid.replace("-", "").substring(0, 12).toUpperCase(); - - if (storageChanged) { - writeToFile(); - } - } - - /** - * Persist to storage. - */ - void writeToFile() { - Storage storage = this.storage; - if (storage == null) { - return; - } - storage.put("devicename", dataStore.config.devicename); - storage.put("udn", dataStore.config.uuid); - } - - public void resetStorage() { - this.storage = null; - } /** * Starts a pairing timeout thread if dataStore.config.linkbutton is set to true. * Stops any already setup timer. */ - void checkPairingTimeout() { - stopPairingTimeoutThread(); + public void check(ConfigurationAdmin configAdmin, HueDataStore dataStore) { + stop(); if (dataStore.config.linkbutton) { logger.info("Hue Emulation pairing enabled for {}s at {}", dataStore.config.networkopenduration, - RESTApi.PATH); + HueEmulationService.RESTAPI_PATH); Thread thread = new Thread(() -> { try { Thread.sleep(dataStore.config.networkopenduration * 1000); org.osgi.service.cm.Configuration configuration = configAdmin - .getConfiguration("org.openhab.hueemulation"); + .getConfiguration(HueEmulationService.CONFIG_PID); Dictionary dictionary = configuration.getProperties(); dictionary.put(HueEmulationConfig.CONFIG_PAIRING_ENABLED, false); dictionary.put(HueEmulationConfig.CONFIG_CREATE_NEW_USER_ON_THE_FLY, false); @@ -111,11 +60,11 @@ void checkPairingTimeout() { pairingTimeoutThread = thread; thread.start(); } else { - logger.info("Hue Emulation pairing disabled. Service available under {}", RESTApi.PATH); + logger.info("Hue Emulation pairing disabled. Service available under {}", HueEmulationService.RESTAPI_PATH); } } - void stopPairingTimeoutThread() { + public void stop() { Thread thread = pairingTimeoutThread; if (thread != null) { thread.interrupt(); diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RESTApi.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RESTApi.java deleted file mode 100644 index b0007c33824af..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RESTApi.java +++ /dev/null @@ -1,434 +0,0 @@ -/** - * Copyright (c) 2010-2019 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.io.Writer; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import java.util.UUID; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.smarthome.core.events.EventPublisher; -import org.eclipse.smarthome.core.items.events.ItemEventFactory; -import org.eclipse.smarthome.core.types.Command; -import org.openhab.io.hueemulation.internal.dto.HueDataStore; -import org.openhab.io.hueemulation.internal.dto.HueDevice; -import org.openhab.io.hueemulation.internal.dto.HueNewLights; -import org.openhab.io.hueemulation.internal.dto.HueStateChange; -import org.openhab.io.hueemulation.internal.dto.HueUnauthorizedConfig; -import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeRequest; -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.HueResponse.HueErrorMessage; -import org.openhab.io.hueemulation.internal.dto.response.HueSuccessCreateGroup; -import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseCreateUser; -import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseStartSearchLights; -import org.openhab.io.hueemulation.internal.dto.response.HueSuccessResponseStateChanged; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.gson.Gson; -import com.google.gson.JsonParseException; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonWriter; - -/** - * Handles all REST API Requests - * - * @author David Graeff - Initial contribution - */ -@NonNullByDefault -public class RESTApi { - public static final String PATH = "/api"; - private final Logger logger = LoggerFactory.getLogger(HueEmulationService.class); - private final HueDataStore ds; - private final Gson gson; - private final UserManagement userManagement; - private final ConfigManagement configManagement; - private @NonNullByDefault({}) EventPublisher eventPublisher; - - public static enum HttpMethod { - GET, - POST, - PUT, - DELETE - } - - public RESTApi(HueDataStore ds, UserManagement userManagement, ConfigManagement configManagement, Gson gson) { - this.ds = ds; - this.userManagement = userManagement; - this.configManagement = configManagement; - this.gson = gson; - } - - public void setEventPublisher(@Nullable EventPublisher eventPublisher) { - this.eventPublisher = eventPublisher; - } - - /** - * Cuts of the first part of a path and returns the remaining one. - */ - private Path remaining(Path path) { - if (path.getNameCount() > 1) { - return path.subpath(1, path.getNameCount()); - } else { - return Paths.get("/"); - } - } - - /** - * Handles /api and forwards any deeper path - * - * @param isDebug - */ - @SuppressWarnings("null") - public int handle(HttpMethod method, String body, Writer out, Path path, boolean isDebug) - throws IOException, JsonParseException { - if (!"api".equals(path.getName(0).toString())) { - return 404; - } - - if (path.getNameCount() == 1) { // request for API key - if (method != HttpMethod.POST) { - return 405; - } - if (!ds.config.linkbutton) { - return 10403; - } - - final HueCreateUser userRequest; - userRequest = gson.fromJson(body, HueCreateUser.class); - if (userRequest.devicetype == null || userRequest.devicetype.isEmpty()) { - throw new JsonParseException("devicetype not given"); - } - - String apiKey = userRequest.username; - if (apiKey == null || apiKey.length() == 0) { - apiKey = UUID.randomUUID().toString(); - } - userManagement.addUser(apiKey, userRequest.devicetype); - - try (JsonWriter writer = new JsonWriter(out)) { - HueSuccessResponseCreateUser h = new HueSuccessResponseCreateUser(apiKey); - gson.toJson(Collections.singleton(new HueResponse(h)), new TypeToken>() { - }.getType(), writer); - } - return 200; - } - - updateDataStore(); - - Path userPath = remaining(path); - - return handleUser(method, body, out, userPath.getName(0).toString(), remaining(userPath), path, isDebug); - } - - /** - * Handles /api/config and /api/{user-name} and forwards any deeper path - */ - public int handleUser(HttpMethod method, String body, Writer out, String userName, Path remainingPath, Path fullURI, - boolean isDebug) throws IOException, JsonParseException { - - if ("config".equals(userName)) { // Reduced config - try (JsonWriter writer = new JsonWriter(out)) { - gson.toJson(ds.config, new TypeToken() { - }.getType(), writer); - } - return 200; - } - - if (!userManagement.authorizeUser(userName)) { - if (ds.config.linkbutton && ds.config.createNewUserOnEveryEndpoint) { - userManagement.addUser(userName, "Formerly authorized device"); - } else { - return 403; - } - } - - if (remainingPath.getNameCount() == 0) { /** /api/{username} */ - switch (method) { - case GET: - out.write(gson.toJson(ds)); - return 200; - default: - return 405; - } - - } - - String function = remainingPath.getName(0).toString(); - - switch (function) { - case "lights": - return handleLights(method, body, out, remaining(remainingPath), fullURI, isDebug); - case "groups": - return handleGroups(method, body, out, remaining(remainingPath)); - case "config": - return handleConfig(method, body, out, remaining(remainingPath), userName); - default: - return 404; - } - } - - /** - * Handles /api/{user-name}/config and /api/{user-name}/config/whitelist - * The own whitelisted user can remove itself with a DELETE - */ - public int handleConfig(HttpMethod method, String body, Writer out, Path remainingPath, String authorizedUser) - throws IOException, JsonParseException { - if (remainingPath.getNameCount() == 0) { - switch (method) { - case GET: - out.write(gson.toJson(ds.config)); - return 200; - case PUT: - final HueChangeRequest changes; - changes = gson.fromJson(body, HueChangeRequest.class); - if (changes.devicename != null) { - ds.config.devicename = changes.devicename; - } - if (changes.dhcp != null) { - ds.config.dhcp = changes.dhcp; - } - if (changes.linkbutton != null) { - ds.config.linkbutton = changes.linkbutton; - configManagement.checkPairingTimeout(); - } - configManagement.writeToFile(); - return 200; - default: - return 405; - } - } else if (remainingPath.getNameCount() >= 1 && "whitelist".equals(remainingPath.getName(0).toString())) { - return handleConfigWhitelist(method, out, remaining(remainingPath), authorizedUser); - } else { - return 404; - } - } - - public int handleConfigWhitelist(HttpMethod method, Writer out, Path remainingPath, String authorizedUser) - throws IOException { - switch (remainingPath.getNameCount()) { - case 0: - switch (method) { - case GET: - out.write(gson.toJson(ds.config.whitelist)); - return 200; - default: - return 405; - } - case 1: - String username = remainingPath.getName(0).toString(); - switch (method) { - case GET: - ds.config.whitelist.get(username); - out.write(gson.toJson(ds.config.whitelist)); - return 200; - case DELETE: - // Only own user can be removed - if (username.equals(authorizedUser)) { - userManagement.removeUser(authorizedUser); - return 200; - } else { - return 403; - } - default: - return 405; - } - default: - return 405; - } - } - - @SuppressWarnings({ "null", "unused" }) - public int handleLights(HttpMethod method, String body, Writer out, Path remainingPath, Path fullURI, - boolean isDebug) throws IOException, JsonParseException { - /** /api/{username}/lights */ - if (remainingPath.getNameCount() == 0) { - switch (method) { - case GET: - if (isDebug) { - out.write("Exposed lights:\n\n"); - for (HueDevice hueDevice : ds.lights.values()) { - out.write(hueDevice.toString()); - out.write("\n"); - } - } else { - ds.lights.values().forEach(v -> v.updateState()); - out.write(gson.toJson(ds.lights)); - } - return 200; - case POST: - try (JsonWriter writer = new JsonWriter(out)) { - List responses = new ArrayList<>(); - responses.add(new HueResponse(new HueSuccessResponseStartSearchLights())); - gson.toJson(responses, new TypeToken>() { - }.getType(), writer); - } - return 200; - default: - return 405; - } - } - - String id = remainingPath.getName(0).toString(); - - /** /api/{username}/lights/new */ - if ("new".equals(id)) { - switch (method) { - case GET: - out.write(gson.toJson(new HueNewLights())); - return 200; - default: - return 405; - } - } - - final int hueID; - try { - hueID = new Integer(id); - } catch (NumberFormatException e) { - return 404; - } - - HueDevice hueDevice = ds.lights.get(hueID); - if (hueDevice == null) { - return 404; - } - - /** /api/{username}/lights/{id} */ - if (remainingPath.getNameCount() == 1) { - hueDevice.updateState(); - out.write(gson.toJson(hueDevice)); - return 200; - } - - if (remainingPath.getNameCount() == 2) { - switch (method) { - case PUT: - return handleLightChangeState(fullURI, method, body, out, hueID, hueDevice); - default: - return 405; - } - } - - return 404; - } - - @SuppressWarnings({ "null", "unused" }) - public int handleGroups(HttpMethod method, String body, Writer out, Path remainingPath) throws IOException { - /** /api/{username}/groups */ - if (remainingPath.getNameCount() == 0) { - switch (method) { - case GET: - out.write(gson.toJson(ds.groups)); - return 200; - case POST: - int hueid = ds.generateNextGroupHueID(); - try (JsonWriter writer = new JsonWriter(out)) { - List responses = new ArrayList<>(); - responses.add(new HueResponse(new HueSuccessCreateGroup(hueid))); - gson.toJson(responses, new TypeToken>() { - }.getType(), writer); - } - return 200; - default: - return 405; - } - } - - String id = remainingPath.getName(0).toString(); - - final int hueID; - try { - hueID = new Integer(id); - } catch (NumberFormatException e) { - return 404; - } - - /** /api/{username}/groups/{id} */ - if (remainingPath.getNameCount() == 1) { - Object value = ds.groups.get(hueID); - if (value == null) { - return 404; - } else { - out.write(gson.toJson(value)); - return 200; - } - } - return 404; - } - - /** - * Hue API call to set the state of a light. - * Enpoint: /api/{username}/lights/{id}/state - */ - @SuppressWarnings({ "null", "unused" }) - private int handleLightChangeState(Path fullURI, HttpMethod method, String body, Writer out, int hueID, - HueDevice hueDevice) throws IOException, JsonParseException { - HueStateChange state = gson.fromJson(body, HueStateChange.class); - if (state == null) { - throw new JsonParseException("No state change data received!"); - } - - // logger.debug("Received state change: {}", gson.toJson(state)); - - // Apply new state and collect success, error items - Map successApplied = new TreeMap<>(); - List errorApplied = new ArrayList<>(); - Command command = hueDevice.applyState(state, successApplied, errorApplied); - - // If a command could be created, post it to the framework now - if (command != null) { - logger.debug("sending {} to {}", command, hueDevice.item.getName()); - eventPublisher.post(ItemEventFactory.createCommandEvent(hueDevice.item.getName(), command, "hueemulation")); - } - - // 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. - Path contextPath = fullURI.subpath(2, fullURI.getNameCount() - 1); - List responses = new ArrayList<>(); - successApplied.forEach((t, v) -> { - responses.add(new HueResponse(new HueSuccessResponseStateChanged(contextPath.resolve(t).toString(), v))); - }); - errorApplied.forEach(v -> { - responses.add(new HueResponse(new HueErrorMessage(HueResponse.NOT_AVAILABLE, - contextPath.resolve(v).toString(), "Could not set"))); - }); - - try (JsonWriter writer = new JsonWriter(out)) { - gson.toJson(responses, new TypeToken>() { - }.getType(), writer); - } - return 200; - } - - /** - * Update changing parameters of the data store like the time. - */ - public void updateDataStore() { - ds.config.UTC = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - ds.config.localtime = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - } -} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RuleUtils.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RuleUtils.java new file mode 100644 index 0000000000000..86d94bc1fcb82 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/RuleUtils.java @@ -0,0 +1,372 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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 r[] = 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})?") || r.length != upperTimeParts.length) { + throw new IllegalStateException("Random Time pattern incorrect. Must be 'hh:mm[:ss]'. " + upperTime); + } + for (int i = 0; i < r.length; ++i) { + int n = Integer.parseInt(r[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); + } + r[i] = String.valueOf(n2 > n ? n + diff : n2 + diff); + } + } + + return r; + } + + /** + * 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//groups/1/action" or + * "/api//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 (validation[3].equals("groups") && validation[5].equals("action")) { + HueGroupEntry entry = ds.groups.get(validation[4]); + if (entry == null) { + throw new IllegalStateException("Group does not exist: " + validation[4]); + } + } else if (validation[3].equals("lights") && validation[5].equals("state")) { + 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//groups/1/action" or "/api//lights/1/state" + String[] validation = config.url.split("/"); + if (validation.length < 6 || !validation[0].isEmpty() || !validation[1].equals("api")) { + LOGGER.warn("Hue Rule '{}': Given address in action {} invalid!", ruleName, a.getLabel()); + return null; + } + + if (validation[3].equals("groups") && validation[5].equals("action")) { + 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 (validation[3].equals("lights") && validation[5].equals("state")) { + 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(); + } + + /** + * Creates a trigger based on the given time string. + * According to the Hue + * documentation this can be: + *

+ *

    + *
  • Absolute time [YYYY]-[MM]-[DD]T[hh]:[mm]:[ss] ([date]T[time]) + *
  • Randomized time [YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss] ([date]T[time]A[time]) + *
  • Recurring times W[bbb]/T[hh]:[mm]:[ss] + *
  • Every day of the week given by bbb at given time + *
  • Recurring randomized times W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss] + *
  • 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 + *
  • + *
      + * Timers + *
    • PT[hh]:[mm]:[ss] Timer, expiring after given time + *
    • PT[hh]:[mm]:[ss] Timer, expiring after given time + *
    • PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] Timer with random element + *
    • R[nn]/PT[hh]:[mm]:[ss] Recurring timer + *
    • R/PT[hh]:[mm]:[ss] Recurring timer + *
    • R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] Recurring timer with random element + *
    + *
+ * + * @param localtime 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(String localtime) throws IllegalStateException { + Trigger ruleTrigger; + + // Normalize timer patterns + if (localtime.startsWith("PT")) { + localtime = "R1/" + localtime; + } + if (!localtime.contains("A")) { + localtime += "A"; + } + + if (localtime.startsWith("W")) { // Recurring pattern "W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]" + 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 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); + ruleTrigger = ModuleBuilder.createTrigger().withId("crontrigger").withTypeUID("timer.GenericCronTrigger") + .withConfiguration(triggerConfig).build(); + } else // + + if (localtime.startsWith("R")) { // Timer pattern: R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] + 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"); + } + + ruleTrigger = ModuleBuilder.createTrigger().withId("timertrigger").withTypeUID("timer.TimerTrigger") + .withConfiguration(triggerConfig).build(); + + } else // + + { // Absolute date/time pattern "[YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]" + 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); + } + + ruleTrigger = ModuleBuilder.createTrigger().withId("absolutetrigger") + .withTypeUID("timer.AbsoluteDateTimeTrigger").withConfiguration(triggerConfig).build(); + } + + return ruleTrigger; + } + + public static class TimerConfig { + public String time = ""; + public String randomizeTime = ""; + public @Nullable Integer repeat; + } + + public static @Nullable String timeStringFromTrigger(List triggers) { + Optional 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; + } + } + + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDevice.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/StateUtils.java similarity index 56% rename from bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDevice.java rename to bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/StateUtils.java index b016e2c8f67d9..f04d653c981d8 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDevice.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/StateUtils.java @@ -10,150 +10,56 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.io.hueemulation.internal.dto; +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.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.library.CoreItemFactory; import org.eclipse.smarthome.core.library.types.HSBType; import org.eclipse.smarthome.core.library.types.OnOffType; import org.eclipse.smarthome.core.library.types.PercentType; import org.eclipse.smarthome.core.types.Command; import org.eclipse.smarthome.core.types.State; -import org.openhab.io.hueemulation.internal.DeviceType; +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; /** - * Hue API device object + * 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. + *

+ * It also provides methods to determine the hue type (plug, white bulb, coloured bulb), given an item. * - * @author Dan Cunningham - Initial contribution - * @author David Graeff - Color lights and plugs - * @author Florian Lentz - XY Support + * @author David Graeff - Initial contribution */ @NonNullByDefault -public class HueDevice { - 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 transient Item item; - public transient DeviceType deviceType; - - 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(); +public class StateUtils { /** - * Create a hue device. + * Compute the hue state from a given item state and a device type. * - * @param targetType The state type - * @param item - * @param name - * @param uniqueid - * @param deviceType + * @param itemState The item state + * @param deviceType The device type + * @return A hue light state */ - public HueDevice(Item 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; - default: - case SwitchType: - /** - * 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 plug-in unit"; - 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; + public static AbstractHueState colorStateFromItemState(State itemState, @Nullable DeviceType deviceType) { + if (deviceType == null) { + return new HueStatePlug(false); } - - setState(item.getState()); - } - - private void setState(State itemState) { + AbstractHueState state; switch (deviceType) { case ColorType: if (itemState instanceof HSBType) { @@ -191,29 +97,74 @@ private void setState(State itemState) { state = new HueStatePlug(false); } } + return state; } - private T as(Class type) throws ClassCastException { - return type.cast(state); + /** + * Computes an openHAB item state, given a hue state. + * + *

+ * 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 responses = new ArrayList<>(); + return computeCommandByState(responses, "", new HueStateColorBulb(false), changeRequest); } /** * Apply the new received state from the REST PUT request. * - * @param newState New state - * @param successApplied Output map "state-name"->value: All successfully applied items are added in here - * @param errorApplied Output: All erroneous items are added in here + * @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 @Nullable Command applyState(HueStateChange newState, Map successApplied, - List errorApplied) { - // First synchronize the internal state information with the framework - setState(item.getState()); + public static @Nullable Command computeCommandByState(List responses, String prefix, + AbstractHueState state, HueStateChange newState) { + + // Apply new state and collect success, error items + Map successApplied = new TreeMap<>(); + List errorApplied = new ArrayList<>(); Command command = null; if (newState.on != null) { try { - as(HueStatePlug.class).on = newState.on; + state.as(HueStatePlug.class).on = newState.on; command = OnOffType.from(newState.on); successApplied.put("on", newState.on); } catch (ClassCastException e) { @@ -223,7 +174,7 @@ private T as(Class type) throws ClassCastExcepti if (newState.bri != null) { try { - as(HueStateBulb.class).bri = newState.bri; + 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) { @@ -233,7 +184,7 @@ private T as(Class type) throws ClassCastExcepti if (newState.bri_inc != null) { try { - int newBri = as(HueStateBulb.class).bri + newState.bri_inc; + int newBri = state.as(HueStateBulb.class).bri + newState.bri_inc; if (newBri < 0 || newBri > HueStateBulb.MAX_BRI) { throw new IllegalArgumentException(); } @@ -249,7 +200,7 @@ private T as(Class type) throws ClassCastExcepti if (newState.sat != null) { try { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); c.sat = newState.sat; c.colormode = ColorMode.hs; command = c.toHSBType(); @@ -261,7 +212,7 @@ private T as(Class type) throws ClassCastExcepti if (newState.sat_inc != null) { try { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); int newV = c.sat + newState.sat_inc; if (newV < 0 || newV > HueStateColorBulb.MAX_SAT) { throw new IllegalArgumentException(); @@ -279,7 +230,7 @@ private T as(Class type) throws ClassCastExcepti if (newState.hue != null) { try { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); c.colormode = ColorMode.hs; c.hue = newState.hue; command = c.toHSBType(); @@ -291,7 +242,7 @@ private T as(Class type) throws ClassCastExcepti if (newState.hue_inc != null) { try { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); int newV = c.hue + newState.hue_inc; if (newV < 0 || newV > HueStateColorBulb.MAX_HUE) { throw new IllegalArgumentException(); @@ -314,7 +265,7 @@ private T as(Class type) throws ClassCastExcepti // Adjusting the color temperature implies setting the mode to ct if (state instanceof HueStateColorBulb) { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); c.sat = 0; c.colormode = ColorMode.ct; command = c.toHSBType(); @@ -334,7 +285,7 @@ private T as(Class type) throws ClassCastExcepti // Adjusting the color temperature implies setting the mode to ct if (state instanceof HueStateColorBulb) { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); if (c.colormode != ColorMode.ct) { c.sat = 0; command = c.toHSBType(); @@ -358,9 +309,9 @@ private T as(Class type) throws ClassCastExcepti } if (newState.xy != null) { try { - HueStateColorBulb c = as(HueStateColorBulb.class); + HueStateColorBulb c = state.as(HueStateColorBulb.class); c.colormode = ColorMode.xy; - c.bri = as(HueStateBulb.class).bri; + c.bri = state.as(HueStateBulb.class).bri; c.xy[0] = newState.xy.get(0); c.xy[1] = newState.xy.get(1); command = c.toHSBType(); @@ -371,14 +322,14 @@ private T as(Class type) throws ClassCastExcepti } if (newState.xy_inc != null) { try { - HueStateColorBulb c = as(HueStateColorBulb.class); + 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 = as(HueStateBulb.class).bri; + c.bri = state.as(HueStateBulb.class).bri; c.xy[0] = newX; c.xy[1] = newY; command = c.toHSBType(); @@ -390,30 +341,71 @@ private T as(Class type) throws ClassCastExcepti } } + // 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 void updateItem(Item element) { - item = element; - setState(item.getState()); - - // Just update the item label and item reference - String label = element.getLabel(); - if (label != null) { - name = label; + public static @Nullable DeviceType determineTargetType(ConfigStore cs, Item element) { + String category = element.getCategory(); + String type = element.getType(); + Set tags = element.getTags(); + + // Determine type, heuristically + DeviceType t = null; + + // First consider the category + if (category != null) { + switch (category) { + case "ColorLight": + t = DeviceType.ColorType; + break; + case "Light": + t = DeviceType.SwitchType; + } } - } - /** - * Synchronizes the item state with the hue state object - */ - public void updateState() { - setState(item.getState()); - } + // 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; + } - @Override - public String toString() { - StringBuilder b = new StringBuilder(); - return b.append(name).append(": ").append(type).append("\n\t").append(state.toString()).toString(); + // Last but not least, the item type + if (t == null) { + switch (type) { + case CoreItemFactory.COLOR: + if (cs.colorFilter.size() == 0) { + t = DeviceType.ColorType; + } + break; + case CoreItemFactory.DIMMER: + case CoreItemFactory.ROLLERSHUTTER: + if (cs.whiteFilter.size() == 0) { + t = DeviceType.WhiteTemperatureType; + } + break; + case CoreItemFactory.SWITCH: + if (cs.switchFilter.size() == 0) { + t = DeviceType.SwitchType; + } + break; + } + } + return t; } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationUpnpServer.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UpnpServer.java similarity index 80% rename from bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationUpnpServer.java rename to bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UpnpServer.java index 00c8f1e3639ad..8974101c7a942 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationUpnpServer.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UpnpServer.java @@ -22,22 +22,27 @@ import java.net.SocketException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.io.hueemulation.internal.dto.HueAuthorizedConfig; +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; /** - * Advertises a Hue UPNP compatible bridge + * Advertises a Hue compatible bridge via UPNP. * * @author Dan Cunningham - Initial contribution * @author David Graeff - Refactored */ @NonNullByDefault -public class HueEmulationUpnpServer implements Runnable { - private final Logger logger = LoggerFactory.getLogger(HueEmulationUpnpServer.class); +@Component(configurationPid = { "org.openhab.hueemulation", Component.NAME }) +public class UpnpServer implements Runnable { + private final Logger logger = LoggerFactory.getLogger(UpnpServer.class); /** * According to the UPnP specification, the minimum MaxAge is 1800 seconds. @@ -49,11 +54,20 @@ public class HueEmulationUpnpServer implements Runnable { private static final String MULTI_ADDR = "239.255.255.250"; private final InetAddress MULTI_ADDR_IP; private boolean running; - private final InetAddress address; - private final String[] stVersions = { "", "", "" }; + private String[] stVersions = { "", "", "" }; private @Nullable Thread thread; private @Nullable DatagramSocket socket; - private int webPort; + + @Reference + protected @NonNullByDefault({}) ConfigStore cs; + + UpnpServer() { + try { + MULTI_ADDR_IP = InetAddress.getByName(MULTI_ADDR); + } catch (UnknownHostException e) { + throw new IllegalStateException(e); + } + } /** * Server to send UDP packets onto the network when requested by a Hue API compatible device. @@ -62,30 +76,20 @@ public class HueEmulationUpnpServer implements Runnable { * @param config The hue datastore. Contains the bridgeid and uuid. * @param address IP to advertise for UPNP */ - public HueEmulationUpnpServer(String relativePath, HueAuthorizedConfig config, InetAddress address, int webPort) { - this.webPort = webPort; + @Activate + protected void activate(Map properties) { this.running = true; - this.address = address; - try { - MULTI_ADDR_IP = InetAddress.getByName(MULTI_ADDR); - } catch (UnknownHostException e) { - throw new IllegalStateException(e); - } - - final String[] stVersions = { "upnp:rootdevice", "urn:schemas-upnp-org:device:basic:1", "uuid:" + config.uuid }; + final String[] stVersions = { "upnp:rootdevice", "urn:schemas-upnp-org:device:basic:1", + "uuid:" + cs.getConfig().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=100\r\n" + "LOCATION: %s\r\n" + "SERVER: FreeRTOS/7.4.2, UPnP/1.0, IpBridge/1.15.0\r\n" + "hue-bridgeid: %s\r\n" + "ST: %s\r\n" + "USN: uuid:%s::upnp:rootdevice\r\n\r\n", - MULTI_ADDR, UPNP_PORT_RECV, - "http://" + address.getHostAddress().toString() + ":" + webPort + relativePath, config.bridgeid, - stVersions[i], config.uuid); + MULTI_ADDR, UPNP_PORT_RECV, "http://" + cs.getAddress().getHostAddress().toString() + ":" + + cs.getConfig().discoveryHttpPort + "/description.xml", + cs.ds.config.bridgeid, stVersions[i], cs.getConfig().uuid); } - - } - - public void start() { if (socket != null) { return; } @@ -101,7 +105,8 @@ public void start() { * * @throws InterruptedException */ - public void shutdown() { + @Deactivate + public void deactivate() { Thread thread = this.thread; DatagramSocket socket = this.socket; if (thread == null || socket == null) { @@ -119,6 +124,7 @@ public void shutdown() { thread = null; } + // Runs in a separate thread @Override public void run() { byte[] buf = new byte[1000]; @@ -130,7 +136,7 @@ public void run() { recvSocket.setSoTimeout(MIN_MAX_AGE_MSECS); this.socket = recvSocket; recvSocket.joinGroup(new InetSocketAddress(MULTI_ADDR, UPNP_PORT_RECV), - NetworkInterface.getByInetAddress(address)); + NetworkInterface.getByInetAddress(cs.getAddress())); while (running) { try { @@ -141,7 +147,7 @@ public void run() { continue; } if (recv.getLength() == 0 - || (recv.getAddress() == address && recv.getPort() == sendSocket.getLocalPort())) { + || (recv.getAddress() == cs.getAddress() && recv.getPort() == sendSocket.getLocalPort())) { continue; } String data = new String(recv.getData()); @@ -174,12 +180,4 @@ private void sendUPNPDatagrams(DatagramSocket sendSocket, InetAddress address, i } } } - - InetAddress getAddress() { - return address; - } - - public int getWebPort() { - return webPort; - } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UserManagement.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UserManagement.java deleted file mode 100644 index b68e3a3a0684b..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/UserManagement.java +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Copyright (c) 2010-2019 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.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.smarthome.core.storage.Storage; -import org.openhab.io.hueemulation.internal.dto.HueDataStore; -import org.openhab.io.hueemulation.internal.dto.HueUserAuth; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Manages users of this emulated HUE bridge. - * - * @author David Graeff - Initial contribution - */ -@NonNullByDefault -public class UserManagement { - private final Logger logger = LoggerFactory.getLogger(UserManagement.class); - private final HueDataStore dataStore; - private @Nullable Storage storage; - - public UserManagement(HueDataStore ds) { - dataStore = ds; - } - - /** - * Load users from disk - */ - public void loadUsersFromFile(Storage storage) { - boolean storageChanged = this.storage != null && this.storage != storage; - this.storage = storage; - for (String id : storage.getKeys()) { - HueUserAuth userAuth = storage.get(id); - if (userAuth == null) { - continue; - } - dataStore.config.whitelist.put(id, userAuth); - } - if (storageChanged) { - writeToFile(); - } - } - - /** - * Checks if the username exists in the whitelist - */ - @SuppressWarnings("null") - public boolean authorizeUser(String userName) throws IOException { - HueUserAuth userAuth = dataStore.config.whitelist.get(userName); - if (userAuth != null) { - userAuth.lastUseDate = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - } - - return userAuth != null; - } - - /** - * Adds a user to the whitelist and persist the user file - */ - public synchronized void addUser(String apiKey, String label) throws IOException { - if (!dataStore.config.whitelist.containsKey(apiKey)) { - logger.debug("APIKey {} added", apiKey); - dataStore.config.whitelist.put(apiKey, new HueUserAuth(label)); - writeToFile(); - } - } - - @SuppressWarnings("null") - public synchronized void removeUser(String apiKey) { - HueUserAuth userAuth = dataStore.config.whitelist.remove(apiKey); - if (userAuth != null) { - logger.debug("APIKey {} removed", apiKey); - writeToFile(); - } - } - - /** - * Persist users to storage. - */ - void writeToFile() { - Storage storage = this.storage; - if (storage == null) { - return; - } - storage.getKeys().forEach(key -> storage.remove(key)); - dataStore.config.whitelist.forEach((id, userAuth) -> storage.put(id, userAuth)); - } - - public void resetStorage() { - this.storage = null; - } -} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/Utils.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/Utils.java deleted file mode 100644 index 84f67dbe66897..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/Utils.java +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (c) 2010-2019 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 javax.servlet.http.HttpServletResponse; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * Network utility methods - * - * @author David Graeff - Initial contribution - */ -@NonNullByDefault -public class Utils { - /** - * Sets Hue API Headers - */ - static void setHeaders(HttpServletResponse response) { - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.setHeader("Access-Control-Allow-Origin", "*"); - response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT"); - response.setHeader("Access-Control-Max-Age", "3600"); - response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); - } - - /** - * 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(); - } - -} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/AbsoluteDateTimeTriggerHandler.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/AbsoluteDateTimeTriggerHandler.java new file mode 100644 index 0000000000000..54a0e966ac578 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/AbsoluteDateTimeTriggerHandler.java @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.scheduler.ScheduledCompletableFuture; +import org.eclipse.smarthome.core.scheduler.Scheduler; +import org.eclipse.smarthome.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. + *

+ * 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HttpActionHandler.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HttpActionHandler.java new file mode 100644 index 0000000000000..5bea59918a1f0 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HttpActionHandler.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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. + *

+ * 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 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; + } + + 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 execute(Map 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueHandlerFactory.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueHandlerFactory.java new file mode 100644 index 0000000000000..8f57c45014022 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueHandlerFactory.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2019 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 TYPES = Arrays + .asList(new String[] { HueRuleConditionHandler.MODULE_TYPE_ID }); + + @Reference + protected @NonNullByDefault({}) ConfigStore configStore; + + @Override + @Deactivate + public void deactivate() { + super.deactivate(); + } + + @Override + public Collection 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); + } else { + logger.error("The module handler type '{}' is not supported.", moduleTypeUID); + } + return null; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueRuleConditionHandler.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueRuleConditionHandler.java new file mode 100644 index 0000000000000..b8e0ccde16b33 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/HueRuleConditionHandler.java @@ -0,0 +1,180 @@ +/** + * Copyright (c) 2010-2019 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 java.util.function.Predicate; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.OpenClosedType; +import org.eclipse.smarthome.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.ConfigStore; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This condition is parameterized with Hue rule condition arguments. A Hue rule works + * on the Hue datastore and consideres lights / groups / sensors that are available there. + * + * @author David Graeff - Initial contribution + */ +@NonNullByDefault +public class HueRuleConditionHandler extends BaseModuleHandler 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"; + + protected final HueRuleEntry.Condition config; + protected String itemUID; + + protected Predicate equalLargerSmallerPredicate; + + @SuppressWarnings({ "null", "unused" }) + public HueRuleConditionHandler(Condition module, ConfigStore configStore) { + super(module); + config = module.getConfiguration().as(HueRuleEntry.Condition.class); + + // pattern: "/sensors/2/state/buttonevent" + String validation[] = config.address.split("/"); + String uid = validation[2]; + + if (validation[1].equals("groups") && validation[3].equals("action")) { + HueGroupEntry entry = configStore.ds.groups.get(uid); + if (entry == null) { + throw new IllegalStateException("Group does not exist: " + uid); + } + itemUID = entry.groupItem.getUID(); + } else if (validation[1].equals("lights") && validation[3].equals("state")) { + HueLightEntry entry = configStore.ds.lights.get(uid); + if (entry == null) { + throw new IllegalStateException("Light does not exist: " + uid); + } + itemUID = entry.item.getUID(); + } else if (validation[1].equals("sensors") && validation[3].equals("state")) { + HueSensorEntry entry = configStore.ds.sensors.get(uid); + if (entry == null) { + throw new IllegalStateException("Sensor does not exist: " + uid); + } + itemUID = entry.item.getUID(); + } else { + throw new IllegalStateException("Can only handle groups and lights"); + } + + if (itemUID == null) { + throw new IllegalStateException("Can only handle groups and lights"); + } + + String value = config.value; + switch (config.operator) { + case eq: + if (value == null) { + throw new IllegalStateException("Equal operator requires a value!"); + } + equalLargerSmallerPredicate = 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); + + equalLargerSmallerPredicate = 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); + + equalLargerSmallerPredicate = 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!"); + } + default: + equalLargerSmallerPredicate = s -> true; + break; + } + } + + @NonNullByDefault({}) + @Override + public boolean isSatisfied(Map context) { + 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!"); + 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 equalLargerSmallerPredicate.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: + case in: // time condition not processed in this module type + case not_in: + default: + return false; + } + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RemoveRuleActionHandler.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RemoveRuleActionHandler.java new file mode 100644 index 0000000000000..c7286b1bec280 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RemoveRuleActionHandler.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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. + *

+ * This is very useful for rules that should execute only once etc. + * + * @author David Graeff - Initial contribution + */ +@NonNullByDefault +public class RemoveRuleActionHandler extends BaseModuleHandler 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 execute(Map context) { + ruleRegistry.remove(ruleUID); + return null; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RulesHandlerFactory.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RulesHandlerFactory.java new file mode 100644 index 0000000000000..683a18defbb62 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/RulesHandlerFactory.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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.openhab.core.automation.internal.module.handler.TimerModuleHandlerFactory; +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(TimerModuleHandlerFactory.class); + + private static final Collection 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 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerModuleExHandlerFactory.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerModuleExHandlerFactory.java new file mode 100644 index 0000000000000..98bab8b3c5290 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerModuleExHandlerFactory.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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.openhab.core.automation.internal.module.handler.TimerModuleHandlerFactory; +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(TimerModuleHandlerFactory.class); + + private static final Collection 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 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerTriggerHandler.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerTriggerHandler.java new file mode 100644 index 0000000000000..a6e0cbec60ba9 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/TimerTriggerHandler.java @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.scheduler.ScheduledCompletableFuture; +import org.eclipse.smarthome.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. + *

+ * A random factor and repeat times can also be configured. + * + * @author David Graeff - Initial contribution + */ +@NonNullByDefault +public class TimerTriggerHandler extends BaseTriggerModuleHandler implements Callable { + + 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() throws Exception { + ((TriggerHandlerCallback) callback).triggered(module, null); + config.repeat -= 1; + if (config.repeat == 0) { + schedule = null; + } else { + scheduleJob(); + } + return duration; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/HueRuleTriggerConfig.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/HueRuleTriggerConfig.java new file mode 100644 index 0000000000000..2678aeec864c6 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/HueRuleTriggerConfig.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2019 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; +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/ItemCommandActionConfig.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/ItemCommandActionConfig.java new file mode 100644 index 0000000000000..5de274f9ae051 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/automation/dto/ItemCommandActionConfig.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2019 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 = ""; +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/AbstractHueState.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/AbstractHueState.java index 729bd4804c386..5bb112d7dcf94 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/AbstractHueState.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/AbstractHueState.java @@ -31,4 +31,8 @@ public static enum AlertEnum { } public String alert = AlertEnum.none.name(); + + public T as(Class type) throws ClassCastException { + return type.cast(this); + } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueAuthorizedConfig.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueAuthorizedConfig.java index ff922d537f8f5..fe9d8e9d33db6 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueAuthorizedConfig.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueAuthorizedConfig.java @@ -12,12 +12,20 @@ */ 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 * @@ -25,14 +33,14 @@ */ @NonNullByDefault public class HueAuthorizedConfig extends HueUnauthorizedConfig { - public String uuid = "5673dfa7-272c-4315-9955-252cdd86131c"; + 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 = "Philips Hue"; + public String devicename = "openHAB"; public String fwversion = "0x262e0500"; @@ -52,4 +60,19 @@ public class HueAuthorizedConfig extends HueUnauthorizedConfig { public int proxyport = 0; public final Map whitelist = new TreeMap<>(); + + /** + * 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 { + @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, typeOfSrc); + return jsonSubscription; + } + } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueCapability.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueCapability.java new file mode 100644 index 0000000000000..d17d9b727421d --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueCapability.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2019 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 + *

+ * 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; +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDataStore.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDataStore.java index 7c2d077b68f24..1f3e56634d9e6 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDataStore.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueDataStore.java @@ -29,33 +29,51 @@ @NonNullByDefault public class HueDataStore { public HueAuthorizedConfig config = new HueAuthorizedConfig(); - public TreeMap lights = new TreeMap<>(); - public TreeMap groups = new TreeMap<>(); - public Map scenes = new TreeMap<>(); - public Map rules = new TreeMap<>(); - public Map sensors = new TreeMap<>(); - public Map schedules = new TreeMap<>(); + public TreeMap lights = new TreeMap<>(); + public TreeMap groups = new TreeMap<>(); + public Map scenes = new TreeMap<>(); + public Map rules = new TreeMap<>(); + public Map sensors = new TreeMap<>(); + public Map schedules = new TreeMap<>(); public Map resourcelinks = Collections.emptyMap(); + public Map 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 HueGroup("All lights", null, Collections.emptyMap())); + groups.put("0", new HueGroupEntry("All lights", null, null)); } - public int generateNextLightHueID() { - return lights.size() == 0 ? 1 : new Integer(lights.lastKey().intValue() + 1); + public void resetSensors() { + sensors.clear(); } - public int generateNextGroupHueID() { - return groups.size() == 0 ? 1 : new Integer(groups.lastKey().intValue() + 1); + public static class Dummy { } - 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; + } } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroup.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroup.java deleted file mode 100644 index 4f76ca8800cee..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroup.java +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Copyright (c) 2010-2019 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.Map; -import java.util.stream.Collectors; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.smarthome.core.items.GroupItem; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonSerializationContext; -import com.google.gson.JsonSerializer; - -/** - * Hue API group object - * - * @author Dan Cunningham - Initial contribution - */ -@NonNullByDefault -public class HueGroup { - public HueStateColorBulb action = new HueStateColorBulb(); - public String type = "LightGroup"; - public String name; - public List lights = Collections.emptyList(); - - public transient @Nullable GroupItem groupItem; - public transient Map itemUIDtoHueID; - - public HueGroup(String name, @Nullable GroupItem groupItem, Map itemUIDtoHueID) { - this.name = name; - this.groupItem = groupItem; - this.itemUIDtoHueID = itemUIDtoHueID; - } - - public void updateItem(GroupItem element) { - groupItem = element; - } - - /** - * This custom serializer computes the {@link HueGroup#lights} list, before serializing. - * It does so, by looking up all item members of the references groupItem and map them to - * either a known hue ID or filtering them out. - * - */ - @NonNullByDefault({}) - public static class Serializer implements JsonSerializer { - - @SuppressWarnings("null") - @Override - public JsonElement serialize(HueGroup product, Type type, JsonSerializationContext jsc) { - GroupItem item = product.groupItem; - if (item != null) { - product.lights = item.getMembers().stream().map(gitem -> product.itemUIDtoHueID.get(gitem.getUID())) - .filter(id -> id != null).map(e -> String.valueOf(e)).collect(Collectors.toList()); - } - - JsonObject o = new JsonObject(); - o.addProperty("name", product.name); - o.addProperty("type", product.type); - o.add("action", jsc.serialize(product.action)); - o.add("lights", jsc.serialize(product.lights)); - return o; - } - } - -} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroupEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroupEntry.java new file mode 100644 index 0000000000000..8a2a883c521e5 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueGroupEntry.java @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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 lights = Collections.emptyList(); + public List 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 { + + 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; + } + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueLightEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueLightEntry.java new file mode 100644 index 0000000000000..9db169df660bf --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueLightEntry.java @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.library.items.StringItem; +import org.openhab.io.hueemulation.internal.DeviceType; +import org.openhab.io.hueemulation.internal.StateUtils; + +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 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; + default: + case SwitchType: + /** + * 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 { + static class HueDeviceHelper extends HueLightEntry { + + } + + @Override + public JsonElement serialize(HueLightEntry product, Type type, JsonSerializationContext context) { + + product.state = StateUtils.colorStateFromItemState(product.item.getState(), product.deviceType); + 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); + + String label = element.getLabel(); + if (label != null) { + name = label; + } + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueRuleEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueRuleEntry.java new file mode 100644 index 0000000000000..6a6bc558b7946 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueRuleEntry.java @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2010-2019 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: + *

+ * + *

+     * {
+     *       "address": "/sensors/2/state/buttonevent",
+     *       "operator": "eq",
+     *       "value": "16"
+     * },
+     * 
+ */ + 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 conditions = new ArrayList<>(); + public List 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, JsonDeserializer { + @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; + } + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneEntry.java new file mode 100644 index 0000000000000..e5a6ab78e34c9 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneEntry.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2019 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 lights; + public @Nullable String group; + + HueSceneEntry() { + name = ""; + } + + public HueSceneEntry(@Nullable String name) { + this.name = name != null ? name : ""; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneWithLightstates.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneWithLightstates.java new file mode 100644 index 0000000000000..ae77db63784ec --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSceneWithLightstates.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2019 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 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueScheduleEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueScheduleEntry.java new file mode 100644 index 0000000000000..b38b17e58cf54 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueScheduleEntry.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2019 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.HueCommand; +import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeScheduleEntry; + +/** + * 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSensorEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSensorEntry.java new file mode 100644 index 0000000000000..689f1f19dddeb --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueSensorEntry.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2010-2019 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.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.library.CoreItemFactory; +import org.eclipse.smarthome.core.library.types.DecimalType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.OpenClosedType; + +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 { + + 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(); + switch (product.type) { + case "CLIPOpenClose": + state.addProperty("open", ((OpenClosedType) product.item.getState()) == OpenClosedType.OPEN); + break; + case "CLIPGenericStatus": + state.addProperty("status", ((DecimalType) product.item.getState()).intValue()); + break; + case "CLIPTemperature": + state.addProperty("temperature", ((DecimalType) product.item.getState()).intValue()); + break; + case "CLIPLightLevel": + state.addProperty("lightlevel", ((DecimalType) product.item.getState()).intValue()); + state.addProperty("dark", false); + state.addProperty("daylight", false); + break; + case "CLIPGenericFlag": + state.addProperty("flag", ((OnOffType) product.item.getState()) == OnOffType.ON); + break; + } + json.getAsJsonObject().add("state", state); + return json; + } + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateBulb.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateBulb.java index 22e396b454373..6503592048f0b 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateBulb.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateBulb.java @@ -50,6 +50,15 @@ public HueStateBulb(PercentType brightness, boolean on) { this.bri = (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; diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateColorBulb.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateColorBulb.java index d0b0a86498eb6..b313ffaed643a 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateColorBulb.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateColorBulb.java @@ -85,14 +85,15 @@ public HueStateColorBulb(HSBType hsb) { public HSBType toHSBType() { if (colormode == ColorMode.xy) { int i; - double d = this.xy[0]; - d = this.xy[1]; - double y = ((double) this.bri) / 100.0d; + 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); + 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 { @@ -108,32 +109,34 @@ public HSBType toHSBType() { } double delta = maxValue - minValue; if (maxValue <= 0.0d) { - return new HSBType(new DecimalType(0), new PercentType(100), new PercentType((this.bri * 100) / MAX_BRI)); + return new HSBType(new DecimalType(0), new PercentType(100), + new PercentType((this.bri * 100) / MAX_BRI)); } double h; - if (((double) r) >= maxValue) { - h = ((double) (g - b)) / delta; - } else if (((double) g) >= maxValue) { - h = 2.0d + (((double) (b - r)) / delta); + if ((r) >= maxValue) { + h = (g - b) / delta; + } else if ((g) >= maxValue) { + h = 2.0d + ((b - r) / delta); } else { - h = 4.0d + (((double) (r - g)) / delta); + 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) / ((double) MAX_SAT)); + int percentSat = (int) ((100.0d * hueSat) / (MAX_SAT)); if (!this.on) { this.bri = 0; } - return new HSBType(new DecimalType((Math.floor(182.04d * h) * 360.0d) / ((double) MAX_HUE)), new PercentType(percentSat), new PercentType((this.bri * 100) / MAX_BRI)); - + return new HSBType(new DecimalType((Math.floor(182.04d * h) * 360.0d) / (MAX_HUE)), + new PercentType(percentSat), new PercentType((this.bri * 100) / MAX_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; } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStatePlug.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStatePlug.java index 1005ffd849667..b6b90b35c4ebf 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStatePlug.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStatePlug.java @@ -12,6 +12,8 @@ */ package org.openhab.io.hueemulation.internal.dto; +import org.eclipse.smarthome.core.library.types.OnOffType; + /** * Hue API state object for plugs * @@ -28,6 +30,10 @@ 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; diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUnauthorizedConfig.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUnauthorizedConfig.java index 499b41e39b769..4664cba149aa4 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUnauthorizedConfig.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUnauthorizedConfig.java @@ -20,7 +20,7 @@ */ public class HueUnauthorizedConfig { public String apiversion = "1.16.0"; - public String bridgeid = "00212EFFFF022F6E"; + public String bridgeid = ""; // Example: 00212EFFFF022F6E public String datastoreversion = "60"; public String starterkitid = ""; public String modelid = "BSB002"; diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuth.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuth.java index 8f0f7fc3fd460..0b356d7352799 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuth.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuth.java @@ -15,29 +15,34 @@ 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. */ - public HueUserAuth() { + HueUserAuth() { } /** * Create a new user * - * @param name Visible name + * @param apikey The hue "username" */ - public HueUserAuth(String name) { - this.name = name; + public HueUserAuth(String appName, String deviceName) { + this.name = appName + "#" + deviceName; this.createDate = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); } -} \ No newline at end of file +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuthWithSecrets.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuthWithSecrets.java new file mode 100644 index 0000000000000..e75b1cf490850 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueUserAuthWithSecrets.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2019 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.smarthome.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 { + public String apiKey = ""; + public String clientKey = ""; + + /** + * 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeRequest.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeRequest.java index a5304b04f7669..08d1ec8d276ba 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeRequest.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeRequest.java @@ -16,11 +16,12 @@ import org.eclipse.jdt.annotation.Nullable; /** - * Multiple endpoints support POST changes, for example: + * Multiple endpoints support PUT changes, for example: *
    *
  • Config: Allows to change the bridge name, dhcp, portalservices, linkbutton *
  • Light: Allows to change the name *
  • Group: Allows to change the name + *
  • Srnsor: Allows to change the name *
* * @author David Graeff - Initial contribution diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeSceneEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeSceneEntry.java new file mode 100644 index 0000000000000..0523dd0d4623a --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeSceneEntry.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2019 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 lights; + + public @Nullable Map lightstates; +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeScheduleEntry.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeScheduleEntry.java new file mode 100644 index 0000000000000..897a8ec834151 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueChangeScheduleEntry.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2019 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; +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCommand.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCommand.java new file mode 100644 index 0000000000000..6e1e3a67b148e --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCommand.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2010-2019 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. + *

+ * 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCreateUser.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCreateUser.java index 8105d48b71cca..7a04b433aefd9 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCreateUser.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueCreateUser.java @@ -13,7 +13,6 @@ package org.openhab.io.hueemulation.internal.dto.changerequest; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; /** * Hue API create user object @@ -23,8 +22,6 @@ */ @NonNullByDefault public class HueCreateUser { - /** The device label/name */ + /** ApplicationName#DeviceName */ public String devicetype = ""; - /** Caller suggested API key ("username"). Usually empty to generate one. Newer hue bridges always generate one. */ - public @Nullable String username; } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateChange.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueStateChange.java similarity index 94% rename from bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateChange.java rename to bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueStateChange.java index ee979757b9660..a3018a9f21ab4 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/HueStateChange.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/changerequest/HueStateChange.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.io.hueemulation.internal.dto; +package org.openhab.io.hueemulation.internal.dto.changerequest; import java.util.List; diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponse.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponse.java index ebbc7ffc28e50..7a52ff3ece764 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponse.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponse.java @@ -26,9 +26,18 @@ public class HueResponse { 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; diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStartSearchLights.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponseSuccessSimple.java similarity index 66% rename from bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStartSearchLights.java rename to bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponseSuccessSimple.java index 2de79c2f9df99..4fb2308ecd869 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStartSearchLights.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueResponseSuccessSimple.java @@ -12,12 +12,18 @@ */ package org.openhab.io.hueemulation.internal.dto.response; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** - * This object describes the right hand side of "success". + * Hue API response base type * * @author David Graeff - Initial contribution */ -public class HueSuccessResponseStartSearchLights implements HueSuccessResponse { - public HueSuccessResponseStartSearchLights() { +@NonNullByDefault +public class HueResponseSuccessSimple { + public final String success; + + public HueResponseSuccessSimple(String success) { + this.success = success; } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessCreateGroup.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessCreateGroup.java index e7a69c3101586..884503cd9afb3 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessCreateGroup.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessCreateGroup.java @@ -26,10 +26,10 @@ * * @author David Graeff - Initial contribution */ -public class HueSuccessCreateGroup implements HueSuccessResponse { - public int id; +public class HueSuccessCreateGroup extends HueSuccessResponse { + public String id; - public HueSuccessCreateGroup(int id) { + public HueSuccessCreateGroup(String id) { this.id = id; } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessGeneric.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessGeneric.java new file mode 100644 index 0000000000000..a46c4ff9d292e --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessGeneric.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2010-2019 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 { + @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(); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponse.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponse.java index 9e2c22450ddb7..da6f29f2dfcae 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponse.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponse.java @@ -17,5 +17,5 @@ * * @author David Graeff - Initial contribution */ -public interface HueSuccessResponse { +public class HueSuccessResponse { } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseCreateUser.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseCreateUser.java index d0624e2bd3694..e93675b189a27 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseCreateUser.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseCreateUser.java @@ -12,6 +12,8 @@ */ 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: @@ -26,10 +28,15 @@ * * @author David Graeff - Initial contribution */ -public class HueSuccessResponseCreateUser implements HueSuccessResponse { +@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) { + public HueSuccessResponseCreateUser(String username, String clientkey) { this.username = username; + this.clientkey = clientkey; } } diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStateChanged.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStateChanged.java index d47626adf980b..8e71d198f501b 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStateChanged.java +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/dto/response/HueSuccessResponseStateChanged.java @@ -37,7 +37,7 @@ * * @author David Graeff - Initial contribution */ -public class HueSuccessResponseStateChanged implements HueSuccessResponse { +public class HueSuccessResponseStateChanged extends HueSuccessResponse { private transient Object value; private transient String relURI; @@ -47,7 +47,6 @@ public HueSuccessResponseStateChanged(String relURI, Object value) { } public static class Serializer implements JsonSerializer { - @Override public JsonElement serialize(HueSuccessResponseStateChanged product, Type type, JsonSerializationContext jsc) { JsonObject jObj = new JsonObject(); diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/ConfigurationAccess.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/ConfigurationAccess.java new file mode 100644 index 0000000000000..34856d9248de7 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/ConfigurationAccess.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2010-2019 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.io.IOException; + +import javax.ws.rs.Consumes; +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.eclipse.smarthome.io.rest.RESTResource; +import org.openhab.io.hueemulation.internal.ConfigStore; +import org.openhab.io.hueemulation.internal.NetworkUtils; +import org.openhab.io.hueemulation.internal.PairingTimeout; +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.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 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 +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class ConfigurationAccess implements RESTResource { + @Reference + protected @NonNullByDefault({}) ConfigStore cs; + @Reference + protected @NonNullByDefault({}) UserManagement userManagement; + @Reference + protected @NonNullByDefault({}) ConfigurationAdmin configAdmin; + + // Not final for test injections + protected PairingTimeout pairingTimeout = new PairingTimeout(); + + @Activate + protected void activate() { + modified(); + } + + @Modified + protected void modified() { + pairingTimeout.check(configAdmin, cs.ds); + } + + @Deactivate + protected void deactivate() { + pairingTimeout.stop(); + } + + @GET + @javax.ws.rs.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() { + }.getType())).build(); + } + + @GET + @javax.ws.rs.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) throws IOException { + if (!userManagement.authorizeUser(username)) { + return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized"); + } + return Response.ok(cs.gson.toJson(cs.ds)).build(); + } + + @GET + @javax.ws.rs.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) throws IOException { + 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 + @javax.ws.rs.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) throws IOException { + 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.ds.config.linkbutton = linkbutton; + pairingTimeout.check(configAdmin, cs.ds); + } + return Response.ok(cs.gson.toJson(cs.ds.config)).build(); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroups.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroups.java new file mode 100644 index 0000000000000..61fd7d1e6e4c1 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroups.java @@ -0,0 +1,522 @@ +/** + * Copyright (c) 2010-2019 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.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.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.core.events.EventPublisher; +import org.eclipse.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.items.GroupItem; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.items.events.ItemEventFactory; +import org.eclipse.smarthome.core.library.CoreItemFactory; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.io.rest.RESTResource; +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.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: + *

    + *
  • Type is any of SWITCH, DIMMER, COLOR, or Group + *
  • The category is "ColorLight" for coloured lights or "Light" for switchables. + *
  • The item is tagged, according to what is set with {@link #setFilterTags(Set, Set, Set)}. + *
+ * + *

+ * A {@link HueLightEntry} instances is created for each found item. + * Those are kept in the given {@link org.openhab.io.hueemulation.internal.dto.HueDataStore}. + *

+ * + *

+ * 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. + *

+ * + *

+ *

+ * + * @author David Graeff - Initial contribution + * @author Florian Schmidt - Removed base type restriction from Group items + */ +@Component +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class LightsAndGroups implements RegistryChangeListener, RESTResource { + private final Logger logger = LoggerFactory.getLogger(LightsAndGroups.class); + private static final String ITEM_TYPE_GROUP = "Group"; + private static final Set 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 + protected @NonNullByDefault({}) EventPublisher eventPublisher; + + /** + * 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.resetGroupsAndLights(); + + itemRegistry.removeRegistryChangeListener(this); + itemRegistry.addRegistryChangeListener(this); + + for (Item item : itemRegistry.getItems()) { + added(item); + } + logger.debug("Added items: {}", + cs.ds.lights.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 (!(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) { + 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); + + List responses = new ArrayList<>(); + String hueID = cs.mapItemUIDtoHueID(hueDevice.item); + Command command = StateUtils.computeCommandByState(responses, "/lights/" + hueID + "/state", hueDevice.state, + newState); + + // If a command could be created, post it to the framework now + if (command != null) { + logger.debug("sending {} to {}", command, id); + eventPublisher.post(ItemEventFactory.createCommandEvent(id, command, "hueemulation")); + } + + return Response.ok(cs.gson.toJson(responses, new TypeToken>() { + }.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 responses = new ArrayList<>(); + String hueID = cs.mapItemUIDtoHueID(groupItem); + Command command = StateUtils.computeCommandByState(responses, "/groups/" + hueID + "/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.post(ItemEventFactory.createCommandEvent(id, command, "hueemulation")); + } + + return Response.ok(cs.gson.toJson(responses, new TypeToken>() { + }.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 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"); + } + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Rules.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Rules.java new file mode 100644 index 0000000000000..cd093d354e5c0 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Rules.java @@ -0,0 +1,376 @@ +/** + * Copyright (c) 2010-2019 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.io.IOException; +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.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.io.rest.RESTResource; +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 +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class Rules implements RESTResource, RegistryChangeListener { + 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 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 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 hueConditions, + RuleBuilder builder, List oldTriggers, List oldConditions, ItemRegistry itemRegistry) { + + // Preserve all triggers, conditions that are not part of hue rules + Map triggers = new TreeMap<>(); + triggers.putAll(oldTriggers.stream().filter(a -> !a.getTypeUID().equals("core.ItemStateChangeTrigger")) + .collect(Collectors.toMap(e -> e.getId(), e -> e))); + + Map 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 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 createActions(String uid, List hueActions, List oldActions, + String apikey) { + // Preserve all actions that are not "rules.HttpAction" + List 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) throws IOException { + 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 temp; + + temp = newRuleData.description; + if (temp != null) { + builder.withDescription(temp); + } + + 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"); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Scenes.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Scenes.java new file mode 100644 index 0000000000000..28167bc8a144a --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Scenes.java @@ -0,0 +1,423 @@ +/** + * Copyright (c) 2010-2019 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.io.IOException; +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.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.core.items.GroupItem; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.items.ItemNotFoundException; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.State; +import org.eclipse.smarthome.io.rest.RESTResource; +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 +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class Scenes implements RESTResource, RegistryChangeListener { + 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 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.size() > 0) { + 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) throws IOException { + 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 lights = changeRequest.lights; + if (changeRequest.storelightstate && lights != null) { + @SuppressWarnings("null") + @NonNullByDefault({}) + List 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 lightStates = changeRequest.lightstates; + if (changeRequest.storelightstate && lightStates != null) { + List actions = new ArrayList<>(rule.getActions()); + for (Map.Entry 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 lights = newScheduleData.lights; + if (lights != null) { + @NonNullByDefault({}) + List actions = lights.stream().map(itemID -> itemRegistry.get(itemID)).filter(Objects::nonNull) + .map(item -> actionFromState(cs.mapItemUIDtoHueID(item), item.getState())) + .collect(Collectors.toList()); + 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 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 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"))); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Schedules.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Schedules.java new file mode 100644 index 0000000000000..388ce955d7d17 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Schedules.java @@ -0,0 +1,343 @@ +/** + * Copyright (c) 2010-2019 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.io.IOException; +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.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.io.rest.RESTResource; +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. + *

+ * If the scheduled task should remove itself after completion, a RemoveRuleAction is used in the rule. + *

+ * The actual command execution uses HttpAction. + * + * @author David Graeff - Initial contribution + */ +@Component +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class Schedules implements RESTResource, RegistryChangeListener { + 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). + *

+ * 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 oldActions, + List 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 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) throws IOException { + 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); + + try { + ruleRegistry.add( + createRule(uid, builder, Collections.emptyList(), Collections.emptyList(), newScheduleData, cs.ds)); + } catch (IllegalStateException e) { + return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage()); + } + + return NetworkUtils.singleSuccess(cs.gson, uid, "id"); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Sensors.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Sensors.java new file mode 100644 index 0000000000000..b3b2082ea4138 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/Sensors.java @@ -0,0 +1,287 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.core.events.EventPublisher; +import org.eclipse.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.library.CoreItemFactory; +import org.eclipse.smarthome.io.rest.RESTResource; +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 +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class Sensors implements RegistryChangeListener, RESTResource { + private final Logger logger = LoggerFactory.getLogger(Sensors.class); + private static final Set 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; + @Reference + protected @NonNullByDefault({}) EventPublisher eventPublisher; + + /** + * 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"); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/UserManagement.java b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/UserManagement.java new file mode 100644 index 0000000000000..9bccde9f69634 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/java/org/openhab/io/hueemulation/internal/rest/UserManagement.java @@ -0,0 +1,247 @@ +/** + * Copyright (c) 2010-2019 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.io.IOException; +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.Consumes; +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.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.common.registry.DefaultAbstractManagedProvider; +import org.eclipse.smarthome.core.storage.StorageService; +import org.eclipse.smarthome.io.rest.RESTResource; +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.Component; +import org.osgi.service.component.annotations.Reference; +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; + +/** + * Manages users of this emulated HUE bridge. Stores users in the frameworks storage backend. + *

+ * This is an OSGi component. Usage: + * + *

+ * @Reference
+ * UserManagement userManagment;
+ * 
+ * + * @author David Graeff - Initial contribution + */ +@Component +@NonNullByDefault +@Path("api") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class UserManagement extends DefaultAbstractManagedProvider + implements RESTResource { + private final Logger logger = LoggerFactory.getLogger(UserManagement.class); + + @Reference + protected @NonNullByDefault({}) ConfigStore cs; + + @Override + @Reference(policy = ReferencePolicy.DYNAMIC) + public void setStorageService(@Nullable StorageService storageService) { + super.setStorageService(storageService); + for (HueUserAuthWithSecrets userAuth : getAll()) { + cs.ds.config.whitelist.put(userAuth.getUID(), userAuth); + } + } + + @Override + protected void unsetStorageService(@Nullable StorageService storageService) { + super.unsetStorageService(storageService); + } + + /** + * Checks if the username exists in the whitelist + */ + @SuppressWarnings("null") + public boolean authorizeUser(String userName) { + HueUserAuth userAuth = cs.ds.config.whitelist.get(userName); + if (userAuth != null) { + userAuth.lastUseDate = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + if (cs.ds.config.linkbutton && cs.ds.config.createNewUserOnEveryEndpoint) { + try { + addUser(userName, userName, "Formerly authorized device"); + } catch (IOException e) { + logger.warn("Could not create user on the fly!"); + return false; + } + userAuth = cs.ds.config.whitelist.get(userName); + } + + 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) throws IOException { + synchronized (cs.ds.config.whitelist) { + if (!cs.ds.config.whitelist.containsKey(apiKey)) { + 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 + @Path("test") + public Response testApi() { + return NetworkUtils.singleSuccess(cs.gson, "OK"); + } + + @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(); + try { + addUser(apiKey, clientKey, userRequest.devicetype); + HueSuccessResponseCreateUser h = new HueSuccessResponseCreateUser(apiKey, clientKey); + String result = cs.gson.toJson(Collections.singleton(new HueResponse(h)), new TypeToken>() { + }.getType()); + + return Response.ok(result).build(); + } catch (IOException e) { + return NetworkUtils.singleError(cs.gson, uri, HueResponse.INTERNAL_ERROR, + "Invalid request: " + e.getMessage()); + } + } + + @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, @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."); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/AbsoluteDateTimeTrigger.json b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/AbsoluteDateTimeTrigger.json new file mode 100644 index 0000000000000..08c2e685092d5 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/AbsoluteDateTimeTrigger.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HttpAction.json b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HttpAction.json new file mode 100644 index 0000000000000..f02355bd6249e --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HttpAction.json @@ -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" + } + ] + } + ] +} \ No newline at end of file diff --git a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HueRuleCondition.json b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HueRuleCondition.json new file mode 100644 index 0000000000000..18d74dbbc10ca --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/HueRuleCondition.json @@ -0,0 +1,33 @@ +{ + "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":"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":"address", + "type":"TEXT", + "label":"Hue link address", + "description":"A hue local link address like /sensors/2/state/buttonevent", + "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", + "required":false + } + ] + } + ] +} \ No newline at end of file diff --git a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/RemoveRuleAction.json b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/RemoveRuleAction.json new file mode 100644 index 0000000000000..4e956f1eb0b7b --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/RemoveRuleAction.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/TimerTrigger.json b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/TimerTrigger.json new file mode 100644 index 0000000000000..7772d5a002f23 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/automation/moduletypes/TimerTrigger.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/config/config.xml b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/config/config.xml index 4b57e251b10ed..429997cc195fe 100644 --- a/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/config/config.xml +++ b/bundles/org.openhab.io.hueemulation/src/main/resources/ESH-INF/config/config.xml @@ -45,5 +45,10 @@ 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. true + + + 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. + true + diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/HueRestAPITest.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/HueRestAPITest.java deleted file mode 100644 index 727df5f1ef435..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/HueRestAPITest.java +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Copyright (c) 2010-2019 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 static org.hamcrest.CoreMatchers.*; -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.*; - -import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.nio.file.Paths; - -import javax.servlet.http.HttpServletRequest; - -import org.eclipse.smarthome.core.events.Event; -import org.eclipse.smarthome.core.events.EventPublisher; -import org.eclipse.smarthome.core.items.GroupItem; -import org.eclipse.smarthome.core.library.items.ColorItem; -import org.eclipse.smarthome.core.library.items.SwitchItem; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.openhab.io.hueemulation.internal.RESTApi.HttpMethod; -import org.openhab.io.hueemulation.internal.dto.HueDataStore; -import org.openhab.io.hueemulation.internal.dto.HueDevice; -import org.openhab.io.hueemulation.internal.dto.HueStateColorBulb; -import org.openhab.io.hueemulation.internal.dto.HueStatePlug; -import org.openhab.io.hueemulation.internal.dto.HueUserAuth; - -import com.google.gson.Gson; - -/** - * Tests for {@link RESTApi}. - * - * @author David Graeff - Initial contribution - */ -public class HueRestAPITest { - - private Gson gson; - private HueDataStore ds; - private RESTApi restAPI; - private UserManagement userManagement; - private ConfigManagement configManagement; - @Mock - private EventPublisher eventPublisher; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - gson = new Gson(); - ds = new HueDataStore(); - userManagement = spy(new UserManagement(ds)); - configManagement = spy(new ConfigManagement(ds)); - restAPI = spy(new RESTApi(ds, userManagement, configManagement, gson)); - restAPI.setEventPublisher(eventPublisher); - - // Add simulated lights - ds.lights.put(1, new HueDevice(new SwitchItem("switch"), "switch", DeviceType.SwitchType)); - ds.lights.put(2, new HueDevice(new ColorItem("color"), "color", DeviceType.ColorType)); - ds.lights.put(3, new HueDevice(new ColorItem("white"), "white", DeviceType.WhiteTemperatureType)); - - // Add group item - ds.lights.put(10, - new HueDevice(new GroupItem("white", new SwitchItem("switch")), "white", DeviceType.SwitchType)); - } - - @Test - public void invalidUser() throws IOException { - PrintWriter out = mock(PrintWriter.class); - int result = restAPI.handleUser(HttpMethod.GET, "", out, "testuser", Paths.get(""), Paths.get(""), false); - assertEquals(403, result); - } - - @Test - public void validUser() throws IOException { - PrintWriter out = mock(PrintWriter.class); - ds.config.whitelist.put("testuser", new HueUserAuth("testuser")); - int result = restAPI.handleUser(HttpMethod.GET, "", out, "testuser", Paths.get("/"), Paths.get(""), false); - assertEquals(200, result); - } - - @Test - public void addUser() throws IOException { - PrintWriter out = mock(PrintWriter.class); - HttpServletRequest req = mock(HttpServletRequest.class); - - // GET should fail - int result = restAPI.handle(HttpMethod.GET, "", out, Paths.get("/api"), false); - assertEquals(405, result); - - // Post should create a user, except: if linkbutton not enabled - result = restAPI.handle(HttpMethod.POST, "", out, Paths.get("/api"), false); - assertEquals(10403, result); - - // Post should create a user - ds.config.linkbutton = true; - when(req.getMethod()).thenReturn("POST"); - String body = "{'username':'testuser','devicetype':'user-label'}"; - result = restAPI.handle(HttpMethod.POST, body, out, Paths.get("/api"), false); - assertEquals(result, 200); - assertThat(ds.config.whitelist.get("testuser").name, is("user-label")); - } - - @Test - public void changeSwitchState() throws IOException { - ds.config.whitelist.put("testuser", new HueUserAuth("testuser")); - - assertThat(((HueStatePlug) ds.lights.get(1).state).on, is(false)); - - StringWriter out = new StringWriter(); - String body = "{'on':true}"; - int result = restAPI.handle(HttpMethod.PUT, body, out, Paths.get("/api/testuser/lights/1/state"), false); - assertEquals(200, result); - assertThat(out.toString(), containsString("success")); - assertThat(((HueStatePlug) ds.lights.get(1).state).on, is(true)); - verify(eventPublisher).post(argThat((Event t) -> { - assertThat(t.getPayload(), is("{\"type\":\"OnOff\",\"value\":\"ON\"}")); - return true; - })); - } - - @Test - public void changeGroupItemSwitchState() throws IOException { - ds.config.whitelist.put("testuser", new HueUserAuth("testuser")); - - assertThat(((HueStatePlug) ds.lights.get(10).state).on, is(false)); - - StringWriter out = new StringWriter(); - String body = "{'on':true}"; - int result = restAPI.handle(HttpMethod.PUT, body, out, Paths.get("/api/testuser/lights/10/state"), false); - assertEquals(200, result); - assertThat(out.toString(), containsString("success")); - assertThat(((HueStatePlug) ds.lights.get(10).state).on, is(true)); - verify(eventPublisher).post(argThat((Event t) -> { - assertThat(t.getPayload(), is("{\"type\":\"OnOff\",\"value\":\"ON\"}")); - return true; - })); - } - - @Test - public void changeOnAndBriValues() throws IOException { - ds.config.whitelist.put("testuser", new HueUserAuth("testuser")); - - assertThat(((HueStateColorBulb) ds.lights.get(2).state).on, is(false)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).bri, is(0)); - - String body = "{'on':true,'bri':200}"; - StringWriter out = new StringWriter(); - int result = restAPI.handle(HttpMethod.PUT, body, out, Paths.get("/api/testuser/lights/2/state"), false); - assertEquals(200, result); - assertThat(out.toString(), containsString("success")); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).on, is(true)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).bri, is(200)); - } - - @Test - public void switchOnWithXY() throws IOException { - ds.config.whitelist.put("testuser", new HueUserAuth("testuser")); - - assertThat(((HueStateColorBulb) ds.lights.get(2).state).on, is(false)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).bri, is(0)); - - String body = "{'on':true,'bri':200,'xy':[0.5119,0.4147]}"; - StringWriter out = new StringWriter(); - int result = restAPI.handle(HttpMethod.PUT, body, out, Paths.get("/api/testuser/lights/2/state"), false); - assertEquals(200, result); - assertThat(out.toString(), containsString("success")); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).on, is(true)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).bri, is(200)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).xy[0], is(0.5119)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).xy[1], is(0.4147)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).colormode, is(HueStateColorBulb.ColorMode.xy)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).toHSBType().getHue().intValue(), is((int)27.47722590981918)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).toHSBType().getSaturation().intValue(), is(88)); - assertThat(((HueStateColorBulb) ds.lights.get(2).state).toHSBType().getBrightness().intValue(), is(78)); - } -} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/LightItemsTest.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/LightItemsTest.java deleted file mode 100644 index adc6e6c2c7635..0000000000000 --- a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/LightItemsTest.java +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Copyright (c) 2010-2019 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 static org.hamcrest.CoreMatchers.*; -import static org.junit.Assert.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -import java.io.IOException; -import java.util.Collections; -import java.util.Map; -import java.util.TreeMap; - -import org.eclipse.smarthome.core.items.GroupItem; -import org.eclipse.smarthome.core.items.ItemRegistry; -import org.eclipse.smarthome.core.library.items.SwitchItem; -import org.eclipse.smarthome.core.storage.Storage; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.openhab.io.hueemulation.internal.dto.HueDataStore; -import org.openhab.io.hueemulation.internal.dto.HueDevice; -import org.openhab.io.hueemulation.internal.dto.HueStatePlug; - -import com.google.gson.Gson; - -/** - * Tests for {@link LightItems}. - * - * @author David Graeff - Initial contribution - */ -public class LightItemsTest { - private Gson gson; - private HueDataStore ds; - private LightItems lightItems; - - @Mock - private ItemRegistry itemRegistry; - - @Mock - Storage storage; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - when(itemRegistry.getItems()).thenReturn(Collections.emptyList()); - gson = new Gson(); - ds = new HueDataStore(); - lightItems = spy(new LightItems(ds)); - lightItems.setItemRegistry(itemRegistry); - lightItems.setFilterTags(Collections.singleton("Switchable"), Collections.singleton("ColorLighting"), - Collections.singleton("Lighting")); - verify(itemRegistry).getItems(); - } - - @Test - public void loadStorage() throws IOException { - Map itemUIDtoHueID = new TreeMap<>(); - itemUIDtoHueID.put("switch1", 12); - when(storage.getKeys()).thenReturn(itemUIDtoHueID.keySet()); - when(storage.get(eq("switch1"))).thenReturn(itemUIDtoHueID.get("switch1")); - lightItems.loadMappingFromFile(storage); - } - - @Test - public void addSwitchableByCategory() throws IOException { - SwitchItem item = new SwitchItem("switch1"); - item.setCategory("Light"); - lightItems.added(item); - HueDevice device = ds.lights.get(lightItems.itemUIDtoHueID.get("switch1")); - assertThat(device.item, is(item)); - assertThat(device.state, is(instanceOf(HueStatePlug.class))); - - } - - @Test - public void addSwitchableByTag() throws IOException { - SwitchItem item = new SwitchItem("switch1"); - item.addTag("Switchable"); - lightItems.added(item); - HueDevice device = ds.lights.get(lightItems.itemUIDtoHueID.get("switch1")); - assertThat(device.item, is(item)); - assertThat(device.state, is(instanceOf(HueStatePlug.class))); - } - - @Test - public void addGroupSwitchableByTag() throws IOException { - GroupItem item = new GroupItem("group1", new SwitchItem("switch1")); - item.addTag("Switchable"); - lightItems.added(item); - HueDevice device = ds.lights.get(lightItems.itemUIDtoHueID.get("group1")); - assertThat(device.item, is(item)); - assertThat(device.state, is(instanceOf(HueStatePlug.class))); - } - - @Test - public void addGroupWithoutTypeByTag() throws IOException { - GroupItem item = new GroupItem("group1", null); - item.addTag("Switchable"); - - lightItems.added(item); - - HueDevice device = ds.lights.get(lightItems.itemUIDtoHueID.get("group1")); - assertThat(device.item, is(item)); - assertThat(device.state, is(instanceOf(HueStatePlug.class))); - assertThat(ds.groups.get(lightItems.itemUIDtoHueID.get("group1")).groupItem, is(item)); - } - - @Test - public void removeGroupWithoutTypeAndTag() throws IOException { - String groupName = "group1"; - GroupItem item = new GroupItem(groupName, null); - item.addTag("Switchable"); - lightItems.added(item); - Integer hueId = lightItems.itemUIDtoHueID.get(groupName); - - lightItems.updated(item, new GroupItem(groupName, null)); - - assertThat(lightItems.itemUIDtoHueID.get(groupName), nullValue()); - assertThat(ds.lights.get(hueId), nullValue()); - assertThat(ds.groups.get(hueId), nullValue()); - } - - @Test - public void updateSwitchable() throws IOException { - SwitchItem item = new SwitchItem("switch1"); - item.setLabel("labelOld"); - item.addTag("Switchable"); - lightItems.added(item); - Integer hueID = lightItems.itemUIDtoHueID.get("switch1"); - HueDevice device = 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"); - lightItems.updated(item, newitem); - device = 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"); - lightItems.updated(newitem, newitemWithoutTag); - - device = ds.lights.get(hueID); - assertThat(device, nullValue()); - assertThat(lightItems.itemUIDtoHueID.get("switch1"), nullValue()); - } -} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/CommonSetup.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/CommonSetup.java new file mode 100644 index 0000000000000..04d8ece0a00b5 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/CommonSetup.java @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2010-2019 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.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.Dictionary; +import java.util.Hashtable; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; + +import org.eclipse.smarthome.core.events.EventPublisher; +import org.eclipse.smarthome.core.items.MetadataRegistry; +import org.eclipse.smarthome.core.net.NetworkAddressService; +import org.eclipse.smarthome.core.storage.Storage; +import org.eclipse.smarthome.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.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +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. + *

+ * 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 { + + @Spy + UserManagement userManagement; + + @Mock + EventPublisher eventPublisher; + + ConfigStore cs; + + @Mock + ConfigurationAdmin configAdmin; + + @Mock + org.osgi.service.cm.Configuration configAdminConfig; + + @Mock + NetworkAddressService networkAddressService; + + MetadataRegistry metadataRegistry = new DummyMetadataRegistry(); + + StorageService storageService = new StorageService() { + @Override + public Storage getStorage(String name, ClassLoader classLoader) { + return getStorage(name); + } + + @SuppressWarnings("unchecked") + @Override + public Storage getStorage(String name) { + if (name.equals("hueEmulationUsers")) { + return (Storage) new DummyUsersStorage(); + } + return null; + } + }; + + public Client client; + public HttpServer server; + public String basePath; + + public CommonSetup(boolean withMetadata) throws IOException { + MockitoAnnotations.initMocks(this); + Mockito.when(configAdmin.getConfiguration(ArgumentMatchers.anyString())).thenReturn(configAdminConfig); + Dictionary mockProperties = new Hashtable<>(); + Mockito.when(configAdminConfig.getProperties()).thenReturn(mockProperties); + Mockito.when(networkAddressService.getPrimaryIpv4HostAddress()).thenReturn("127.0.0.1"); + + if (withMetadata) { + cs = new ConfigStore(networkAddressService, configAdmin, metadataRegistry); + } else { + cs = new ConfigStoreWithoutMetadata(networkAddressService, configAdmin); + } + cs.activate(Collections.singletonMap("uuid", "demouuid")); + cs.switchFilter = Collections.singleton("Switchable"); + cs.whiteFilter = Collections.singleton("Switchable"); + cs.colorFilter = Collections.singleton("ColorLighting"); + + userManagement.cs = cs; + userManagement.setStorageService(storageService); + + basePath = "http://localhost:8080"; + } + + /** + * 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 rc) { + rc = rc.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); + basePath = "http://localhost:8080/api"; + // client = ClientBuilder.newClient(new ClientConfig().register(new LoggingFeature( + // Logger.getLogger(LoggingFeature.DEFAULT_LOGGER_NAME), Level.OFF, Verbosity.HEADERS_ONLY, 10))); + client = ClientBuilder.newClient(); + } + + public void dispose() { + if (client != null) { + client.close(); + } + if (server != null) { + server.shutdownNow(); + } + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ItemUIDtoHueIDMappingTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ItemUIDtoHueIDMappingTests.java new file mode 100644 index 0000000000000..85ee94dec8cc0 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ItemUIDtoHueIDMappingTests.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2010-2019 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 java.io.IOException; +import java.util.Collections; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.items.Metadata; +import org.eclipse.smarthome.core.items.MetadataKey; +import org.eclipse.smarthome.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); + + // 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)); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroupsTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroupsTests.java new file mode 100644 index 0000000000000..c1282b79a467e --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/LightsAndGroupsTests.java @@ -0,0 +1,321 @@ +/** + * Copyright (c) 2010-2019 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 java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Response; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.events.Event; +import org.eclipse.smarthome.core.items.GroupItem; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.items.events.ItemCommandEvent; +import org.eclipse.smarthome.core.library.items.ColorItem; +import org.eclipse.smarthome.core.library.items.SwitchItem; +import org.eclipse.smarthome.core.library.types.HSBType; +import org.eclipse.smarthome.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() throws IOException { + 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() throws IOException { + 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 addGroupSwitchableByTag() throws IOException { + 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 addGroupWithoutTypeByTag() throws IOException { + 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() throws IOException { + 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() throws IOException { + 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() throws IOException { + + 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() throws IOException { + + 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 changeOnAndBriValues() throws IOException { + + assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(false)); + assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).bri, is(0)); + + 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() throws IOException { + 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() throws IOException { + 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() throws IOException { + assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).on, is(false)); + assertThat(((HueStateColorBulb) cs.ds.lights.get("2").state).bri, is(0)); + + 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() + throws InterruptedException, ExecutionException, TimeoutException, IOException { + 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; + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RuleConditionHandlerTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RuleConditionHandlerTests.java new file mode 100644 index 0000000000000..14b02197c21bb --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RuleConditionHandlerTests.java @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2010-2019 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.is; +import static org.junit.Assert.assertThat; + +import java.io.IOException; +import java.util.Map; +import java.util.Random; +import java.util.TreeMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.items.GroupItem; +import org.eclipse.smarthome.core.library.items.ContactItem; +import org.eclipse.smarthome.core.library.items.NumberItem; +import org.eclipse.smarthome.core.library.items.SwitchItem; +import org.eclipse.smarthome.core.library.types.DecimalType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.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.ConfigStore; +import org.openhab.io.hueemulation.internal.DeviceType; +import org.openhab.io.hueemulation.internal.RuleUtils; +import org.openhab.io.hueemulation.internal.automation.HueRuleConditionHandler; +import org.openhab.io.hueemulation.internal.dto.HueGroupEntry; +import org.openhab.io.hueemulation.internal.dto.HueLightEntry; +import org.openhab.io.hueemulation.internal.dto.HueSensorEntry; + +/** + * Tests for various rules API endpoints. + * + * @author David Graeff - Initial contribution + */ +@NonNullByDefault +public class RuleConditionHandlerTests { + protected @NonNullByDefault({}) CommonSetup commonSetup; + protected @NonNullByDefault({}) ConfigStore cs; + + @Before + public void setUp() throws IOException { + commonSetup = new CommonSetup(false); + this.cs = commonSetup.cs; + + cs.ds.lights.put("1", new HueLightEntry(new SwitchItem("switch"), "switch", DeviceType.SwitchType)); + + cs.ds.sensors.put("2", new HueSensorEntry(new ContactItem("contact"))); + + cs.ds.groups.put("10", + new HueGroupEntry("name", new GroupItem("white", new NumberItem("number")), DeviceType.SwitchType)); + } + + @After + public void tearDown() { + RuleUtils.random = new Random(); + commonSetup.dispose(); + } + + @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, cs); + } + + @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, cs); + + configuration.put("address", "/lights/1/state"); + c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID) + .withConfiguration(configuration).build(); + new HueRuleConditionHandler(c, cs); + + configuration.put("address", "/sensors/2/state"); + c = ConditionBuilder.create().withId("a").withTypeUID(HueRuleConditionHandler.MODULE_TYPE_ID) + .withConfiguration(configuration).build(); + new HueRuleConditionHandler(c, cs); + } + + @Test + public void equalOperator() { + Map 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, cs); + 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, cs); + 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, cs); + assertThat(subject.isSatisfied(context), is(true)); + } + + @Test + public void gtOperator() { + Map 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, cs); + assertThat(subject.isSatisfied(context), is(true)); + } + + @Test + public void ltOperator() { + Map 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, cs); + assertThat(subject.isSatisfied(context), is(true)); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RulesTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RulesTests.java new file mode 100644 index 0000000000000..828ef7deffb6e --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/RulesTests.java @@ -0,0 +1,280 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.library.items.ColorItem; +import org.eclipse.smarthome.core.library.items.SwitchItem; +import org.eclipse.smarthome.core.library.types.HSBType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.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 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 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 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 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 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>() { + }.getType(); + String body = response.readEntity(String.class); + Map 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")); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SceneTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SceneTests.java new file mode 100644 index 0000000000000..cefd8478e71be --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SceneTests.java @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.items.GroupItem; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.library.items.ColorItem; +import org.eclipse.smarthome.core.library.items.DimmerItem; +import org.eclipse.smarthome.core.library.items.RollershutterItem; +import org.eclipse.smarthome.core.library.items.SwitchItem; +import org.eclipse.smarthome.core.library.types.HSBType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.PercentType; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.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 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 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>() { + }.getType(); + Map fromJson = new Gson().fromJson(response.readEntity(String.class), type); + assertTrue(fromJson.containsKey("demo1")); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ScheduleTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ScheduleTests.java new file mode 100644 index 0000000000000..d31f44960ec3f --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/ScheduleTests.java @@ -0,0 +1,426 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.library.items.ColorItem; +import org.eclipse.smarthome.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 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 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>() { + }.getType(); + Map 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")); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SensorTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SensorTests.java new file mode 100644 index 0000000000000..8dcbacccd0021 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/SensorTests.java @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2010-2019 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.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Response; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.items.GenericItem; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.library.items.ColorItem; +import org.eclipse.smarthome.core.library.items.ContactItem; +import org.eclipse.smarthome.core.library.items.DimmerItem; +import org.eclipse.smarthome.core.library.items.NumberItem; +import org.eclipse.smarthome.core.library.items.RollershutterItem; +import org.eclipse.smarthome.core.library.items.SwitchItem; +import org.eclipse.smarthome.core.library.types.DecimalType; +import org.eclipse.smarthome.core.library.types.HSBType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.OpenClosedType; +import org.eclipse.smarthome.core.library.types.PercentType; +import org.eclipse.smarthome.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.eventPublisher = commonSetup.eventPublisher; + 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() throws IOException { + + 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() throws InterruptedException, ExecutionException, TimeoutException, IOException { + 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")); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/UsersAndConfigTests.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/UsersAndConfigTests.java new file mode 100644 index 0000000000000..3e8946fba4c44 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/UsersAndConfigTests.java @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2010-2019 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 java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +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.PairingTimeout; +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.pairingTimeout = Mockito.mock(PairingTimeout.class); + configurationAccess.configAdmin = commonSetup.configAdmin; + + configurationAccess.activate(); + + commonSetup.start(new ResourceConfig().registerInstances(configurationAccess)); + } + + @After + public void tearDown() { + commonSetup.dispose(); + } + + @Test + public void invalidUser() throws IOException { + assertFalse(commonSetup.userManagement.authorizeUser("blub")); + } + + @Test + public void validUser() throws IOException { + assertTrue(commonSetup.userManagement.authorizeUser("testuser")); + } + + @Test + public void configStoreRestartOnNoUUID() throws IOException { + ConfigStore configStore = new ConfigStoreWithoutMetadata(commonSetup.networkAddressService, + commonSetup.configAdmin); + + configStore.activate(Collections.emptyMap()); + + // No uuid known yet + assertThat(configStore.ds.config.uuid, is("")); + // The config admin service was requested for the service config + Mockito.verify(commonSetup.configAdminConfig).getProperties(); + Dictionary p = commonSetup.configAdminConfig.getProperties(); + // And the service config was updated + assertThat(p.get(HueEmulationConfig.CONFIG_UUID), is(configStore.getConfig().uuid)); + } + + @Test + public void addUser() throws IOException { + // 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)); + assertEquals(response.getStatus(), 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)); + assertEquals(response.getStatus(), 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() + throws InterruptedException, ExecutionException, TimeoutException, IOException { + + // Unauthorized config + Response response; + response = commonSetup.client.target(commonSetup.basePath + "/config").request().get(); + assertEquals(response.getStatus(), 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(); + assertEquals(403, response.getStatus()); + assertThat(response.readEntity(String.class), containsString("error")); + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/ConfigStoreWithoutMetadata.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/ConfigStoreWithoutMetadata.java new file mode 100644 index 0000000000000..3099b8ce87f6e --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/ConfigStoreWithoutMetadata.java @@ -0,0 +1,33 @@ +package org.openhab.io.hueemulation.internal.rest.mocks; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.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) { + super(networkAddressService, configAdmin, null); + } + + @Override + protected void determineHighestAssignedHueID() { + } + + @Override + public @NonNull String mapItemUIDtoHueID(@Nullable Item item) { + if (item == null) { + throw new IllegalArgumentException(); + } + return item.getUID(); + } +} \ No newline at end of file diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyItemRegistry.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyItemRegistry.java new file mode 100644 index 0000000000000..aa7c366b49648 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyItemRegistry.java @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.core.items.Item; +import org.eclipse.smarthome.core.items.ItemNotFoundException; +import org.eclipse.smarthome.core.items.ItemNotUniqueException; +import org.eclipse.smarthome.core.items.ItemRegistry; +import org.eclipse.smarthome.core.items.RegistryHook; + +/** + * @author David Graeff - Initial contribution + */ +@NonNullByDefault +public class DummyItemRegistry implements ItemRegistry { + Map items = new TreeMap<>(); + List> listeners = new ArrayList<>(); + + @Override + public void addRegistryChangeListener(RegistryChangeListener listener) { + listeners.add(listener); + } + + @Override + public Collection getAll() { + return items.values(); + } + + @NonNullByDefault({}) + @Override + public Stream stream() { + return items.values().stream(); + } + + @Override + public @Nullable Item get(@Nullable String key) { + return items.get(key); + } + + @Override + public void removeRegistryChangeListener(RegistryChangeListener listener) { + listeners.remove(listener); + } + + @Override + public Item add(Item element) { + Item put = items.put(element.getUID(), element); + for (RegistryChangeListener l : listeners) { + l.added(element); + } + return put; + } + + @Override + public @Nullable Item update(Item element) { + Item put = items.put(element.getUID(), element); + for (RegistryChangeListener l : listeners) { + l.updated(put, element); + } + return put; + } + + @Override + public @Nullable Item remove(String key) { + Item put = items.remove(key); + for (RegistryChangeListener l : listeners) { + l.removed(put); + } + return put; + } + + @Override + public Item getItem(@Nullable String name) throws ItemNotFoundException { + return items.get(name); + } + + @Override + public Item getItemByPattern(String name) throws ItemNotFoundException, ItemNotUniqueException { + return items.get(name); + } + + @Override + public Collection getItems() { + return items.values(); + } + + @Override + public Collection getItemsOfType(String type) { + return items.values(); + } + + @Override + public Collection getItems(String pattern) { + return items.values(); + } + + @NonNullByDefault({}) + @Override + public Collection getItemsByTag(String... tags) { + return items.values(); + } + + @NonNullByDefault({}) + @Override + public Collection getItemsByTagAndType(String type, String... tags) { + return items.values(); + } + + @NonNullByDefault({}) + @SuppressWarnings("unchecked") + @Override + public Collection getItemsByTag(Class typeFilter, String... tags) { + return (Collection) items.values(); + } + + @Override + public @Nullable Item remove(String itemName, boolean recursive) { + Item put = items.remove(itemName); + for (RegistryChangeListener l : listeners) { + l.removed(put); + } + return put; + } + + @NonNullByDefault({}) + @Override + public void addRegistryHook(RegistryHook hook) { + + } + + @NonNullByDefault({}) + @Override + public void removeRegistryHook(RegistryHook hook) { + + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyMetadataRegistry.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyMetadataRegistry.java new file mode 100644 index 0000000000000..ea582e256adc5 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyMetadataRegistry.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.common.registry.RegistryChangeListener; +import org.eclipse.smarthome.core.items.Metadata; +import org.eclipse.smarthome.core.items.MetadataKey; +import org.eclipse.smarthome.core.items.MetadataRegistry; + +/** + * @author David Graeff - Initial contribution + */ +@NonNullByDefault +public class DummyMetadataRegistry implements MetadataRegistry { + Map items = new HashMap<>(); + List> listeners = new ArrayList<>(); + + @Override + public void addRegistryChangeListener(RegistryChangeListener listener) { + listeners.add(listener); + } + + @Override + public Collection getAll() { + return items.values(); + } + + @Override + public Stream stream() { + return items.values().stream(); + } + + @Override + public @Nullable Metadata get(@Nullable MetadataKey key) { + return items.get(key); + } + + @Override + public void removeRegistryChangeListener(RegistryChangeListener listener) { + listeners.remove(listener); + } + + @Override + public Metadata add(Metadata element) { + Metadata put = items.put(element.getUID(), element); + for (RegistryChangeListener l : listeners) { + l.added(element); + } + return put; + } + + @Override + public @Nullable Metadata update(Metadata element) { + Metadata put = items.put(element.getUID(), element); + for (RegistryChangeListener l : listeners) { + l.updated(put, element); + } + return put; + } + + @Override + public @Nullable Metadata remove(MetadataKey key) { + Metadata put = items.remove(key); + for (RegistryChangeListener l : listeners) { + l.removed(put); + } + return put; + } + + @Override + public boolean isInternalNamespace(String namespace) { + return false; + } + + @Override + public void removeItemMetadata(String name) { + + } + +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyRuleRegistry.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyRuleRegistry.java new file mode 100644 index 0000000000000..6e346e9701814 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyRuleRegistry.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.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 items = new HashMap<>(); + List> listeners = new ArrayList<>(); + + @Override + public void addRegistryChangeListener(RegistryChangeListener listener) { + listeners.add(listener); + } + + @Override + public Collection getAll() { + return items.values(); + } + + @NonNullByDefault({}) + @Override + public Stream stream() { + return items.values().stream(); + } + + @Override + public @Nullable Rule get(@Nullable String key) { + return items.get(key); + } + + @Override + public void removeRegistryChangeListener(RegistryChangeListener listener) { + listeners.remove(listener); + } + + @Override + public Rule add(Rule element) { + Rule put = items.put(element.getUID(), element); + for (RegistryChangeListener l : listeners) { + l.added(element); + } + return put; + } + + @Override + public @Nullable Rule update(Rule element) { + Rule put = items.put(element.getUID(), element); + for (RegistryChangeListener l : listeners) { + l.updated(put, element); + } + return put; + } + + @Override + public @Nullable Rule remove(String key) { + Rule put = items.remove(key); + for (RegistryChangeListener l : listeners) { + l.removed(put); + } + return put; + } + + @NonNullByDefault({}) + @Override + public Collection getByTag(String tag) { + return Collections.emptyList(); + } + + @NonNullByDefault({}) + @Override + public Collection getByTags(String... tags) { + return Collections.emptyList(); + } +} diff --git a/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyUsersStorage.java b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyUsersStorage.java new file mode 100644 index 0000000000000..3c7d95f01b841 --- /dev/null +++ b/bundles/org.openhab.io.hueemulation/src/test/java/org/openhab/io/hueemulation/internal/rest/mocks/DummyUsersStorage.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2010-2019 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.eclipse.smarthome.core.storage.Storage; +import org.openhab.io.hueemulation.internal.dto.HueUserAuthWithSecrets; + +/** + * @author David Graeff - Initial contribution + */ +public class DummyUsersStorage implements Storage { + Map 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(); + } + +} diff --git a/itests/org.openhab.io.hueemulation.tests/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationServiceOSGiTest.java b/itests/org.openhab.io.hueemulation.tests/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationServiceOSGiTest.java index 1b10afe1c8e4f..8d8cece3a97f3 100644 --- a/itests/org.openhab.io.hueemulation.tests/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationServiceOSGiTest.java +++ b/itests/org.openhab.io.hueemulation.tests/src/main/java/org/openhab/io/hueemulation/internal/HueEmulationServiceOSGiTest.java @@ -14,8 +14,7 @@ import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.assertThat; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; import java.io.BufferedInputStream; import java.io.BufferedReader; @@ -28,15 +27,10 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +import org.eclipse.jdt.annotation.Nullable; import org.eclipse.smarthome.core.events.EventPublisher; -import org.eclipse.smarthome.core.items.GroupItem; import org.eclipse.smarthome.core.items.Item; import org.eclipse.smarthome.core.items.ItemRegistry; -import org.eclipse.smarthome.core.items.events.ItemCommandEvent; -import org.eclipse.smarthome.core.library.types.HSBType; -import org.eclipse.smarthome.core.library.types.OnOffType; -import org.eclipse.smarthome.core.service.ReadyMarker; -import org.eclipse.smarthome.core.service.ReadyService; import org.eclipse.smarthome.test.java.JavaOSGiTest; import org.eclipse.smarthome.test.storage.VolatileStorageService; import org.junit.After; @@ -44,14 +38,9 @@ import org.junit.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.openhab.io.hueemulation.internal.dto.HueDevice; -import org.openhab.io.hueemulation.internal.dto.HueStateColorBulb; -import org.openhab.io.hueemulation.internal.dto.HueUnauthorizedConfig; -import org.openhab.io.hueemulation.internal.dto.HueUserAuth; +import org.openhab.core.automation.RuleRegistry; import org.osgi.service.cm.ConfigurationAdmin; -import com.google.gson.Gson; - /** * Integration tests for {@link HueEmulationService}. * @@ -61,7 +50,9 @@ public class HueEmulationServiceOSGiTest extends JavaOSGiTest { private HueEmulationService hueService; VolatileStorageService volatileStorageService = new VolatileStorageService(); - ItemRegistry itemRegistry; + private @Nullable RuleRegistry ruleRegistry; + private @Nullable ItemRegistry itemRegistry; + @Mock ConfigurationAdmin configurationAdmin; @@ -73,7 +64,6 @@ public class HueEmulationServiceOSGiTest extends JavaOSGiTest { String host; - @SuppressWarnings("null") @Before public void setUp() { MockitoAnnotations.initMocks(this); @@ -82,21 +72,17 @@ public void setUp() { itemRegistry = getService(ItemRegistry.class, ItemRegistry.class); assertThat(itemRegistry, notNullValue()); - ReadyService readyService = getService(ReadyService.class, ReadyService.class); - assertThat(readyService, notNullValue()); + + ruleRegistry = getService(RuleRegistry.class, RuleRegistry.class); + assertThat(ruleRegistry, notNullValue()); + hueService = getService(HueEmulationService.class, HueEmulationService.class); assertThat(hueService, notNullValue()); when(item.getName()).thenReturn("itemname"); - hueService.setEventPublisher(eventPublisher); - - readyService.markReady(new ReadyMarker("fake", "org.eclipse.smarthome.model.core")); - waitFor(() -> hueService.discovery != null, 5000, 100); - assertThat(hueService.started, is(true)); - - InetAddress address = hueService.discovery.getAddress(); - host = "http://" + address.getHostAddress() + ":" + String.valueOf(hueService.discovery.getWebPort()); + InetAddress address = hueService.cs.getAddress(); + host = "http://" + address.getHostAddress() + ":" + String.valueOf(hueService.cs.config.discoveryHttpPort); } @After @@ -104,6 +90,14 @@ public void tearDown() { unregisterService(volatileStorageService); } + @Test + public void UpnpServiceTest() throws InterruptedException, ExecutionException, TimeoutException, IOException { + HttpURLConnection c = (HttpURLConnection) new URL(host + "/description.xml").openConnection(); + assertThat(c.getResponseCode(), is(200)); + String body = read(c); + assertThat(body, containsString(hueService.cs.config.uuid)); + } + @SuppressWarnings("null") private String read(HttpURLConnection urlConnection) throws IOException { String result = ""; @@ -127,220 +121,4 @@ private String read(HttpURLConnection urlConnection) throws IOException { return result; } - @Test - public void UnauthorizedAccessTest() - throws InterruptedException, ExecutionException, TimeoutException, IOException { - - // upnp response - HttpURLConnection c = (HttpURLConnection) new URL(host + "/description.xml").openConnection(); - assertThat(c.getResponseCode(), is(200)); - String body = read(c); - assertThat(body, containsString(hueService.ds.config.uuid)); - - // Unauthorized config access - c = (HttpURLConnection) new URL(host + "/api/config").openConnection(); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - HueUnauthorizedConfig config = new Gson().fromJson(body, HueUnauthorizedConfig.class); - assertThat(config.bridgeid, is(hueService.ds.config.bridgeid)); - assertThat(config.name, is(hueService.ds.config.name)); - - // Invalid user name - c = (HttpURLConnection) new URL(host + "/api/invalid/lights").openConnection(); - assertThat(c.getResponseCode(), is(403)); - body = read(c); - assertThat(body, containsString("error")); - - // Add user name (no link button) - body = "{'username':'testuser','devicetype':'label'}"; - c = (HttpURLConnection) new URL(host + "/api").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("POST"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(403)); - - // Add user name (link button) - hueService.ds.config.linkbutton = true; - c = (HttpURLConnection) new URL(host + "/api").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("POST"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("success")); - assertThat(hueService.ds.config.whitelist.get("testuser").name, is("label")); - hueService.ds.config.whitelist.clear(); - - // Add user name without proposing one (the bridge generates one) - body = "{'devicetype':'label'}"; - c = (HttpURLConnection) new URL(host + "/api").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("POST"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("success")); - assertThat(body, containsString(hueService.ds.config.whitelist.keySet().iterator().next())); - } - - @Test - public void LightsTest() throws InterruptedException, ExecutionException, TimeoutException, IOException { - HttpURLConnection c; - String body; - - hueService.ds.config.whitelist.put("testuser", new HueUserAuth("testUserLabel")); - - c = (HttpURLConnection) new URL(host + "/api/testuser/lights").openConnection(); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("{}")); - - hueService.ds.lights.put(1, new HueDevice(item, "switch", DeviceType.SwitchType)); - hueService.ds.lights.put(2, new HueDevice(item, "color", DeviceType.ColorType)); - hueService.ds.lights.put(3, new HueDevice(item, "white", DeviceType.WhiteTemperatureType)); - - // Full access test - c = (HttpURLConnection) new URL(host + "/api/testuser/lights").openConnection(); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("switch")); - assertThat(body, containsString("color")); - assertThat(body, containsString("white")); - - // Single light access test - c = (HttpURLConnection) new URL(host + "/api/testuser/lights/2").openConnection(); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("color")); - } - - @Test - public void DebugTest() throws InterruptedException, ExecutionException, TimeoutException, IOException { - HttpURLConnection c; - String body; - - hueService.ds.config.whitelist.put("testuser", new HueUserAuth("testUserLabel")); - hueService.ds.lights.put(2, new HueDevice(item, "color", DeviceType.ColorType)); - - c = (HttpURLConnection) new URL(host + "/api/testuser/lights?debug=true").openConnection(); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("Exposed lights")); - } - - @Test - public void LightGroupItemSwitchTest() - throws InterruptedException, ExecutionException, TimeoutException, IOException { - HttpURLConnection c; - String body; - - GroupItem gitem = new GroupItem("group", item); - hueService.ds.config.whitelist.put("testuser", new HueUserAuth("testUserLabel")); - hueService.ds.lights.put(7, new HueDevice(gitem, "switch", DeviceType.SwitchType)); - - body = "{'on':true}"; - c = (HttpURLConnection) new URL(host + "/api/testuser/lights/7/state").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("PUT"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("success")); - assertThat(body, containsString("on")); - - verify(eventPublisher).post(argThat(ce -> assertOnValue((ItemCommandEvent) ce, true))); - } - - @Test - public void LightHueTest() throws InterruptedException, ExecutionException, TimeoutException, IOException { - HttpURLConnection c; - String body; - - hueService.ds.config.whitelist.put("testuser", new HueUserAuth("testUserLabel")); - hueService.ds.lights.put(2, new HueDevice(item, "color", DeviceType.ColorType)); - - body = "{'hue':1000}"; - c = (HttpURLConnection) new URL(host + "/api/testuser/lights/2/state").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("PUT"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("success")); - assertThat(body, containsString("hue")); - - verify(eventPublisher).post(argThat(ce -> assertHueValue((ItemCommandEvent) ce, 1000))); - } - - @Test - public void LightSaturationTest() throws InterruptedException, ExecutionException, TimeoutException, IOException { - HttpURLConnection c; - String body; - - hueService.ds.config.whitelist.put("testuser", new HueUserAuth("testUserLabel")); - hueService.ds.lights.put(2, new HueDevice(item, "color", DeviceType.ColorType)); - - body = "{'sat':50}"; - c = (HttpURLConnection) new URL(host + "/api/testuser/lights/2/state").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("PUT"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("success")); - assertThat(body, containsString("sat")); - - verify(eventPublisher).post(argThat(ce -> assertSatValue((ItemCommandEvent) ce, 50))); - } - - /** - * Amazon echos are setting ct only, if commanded to turn a light white. - */ - @Test - public void LightToWhiteTest() throws InterruptedException, ExecutionException, TimeoutException, IOException { - HttpURLConnection c; - String body; - - // We start with a coloured state - when(item.getState()).thenReturn(new HSBType("100,100,100")); - hueService.ds.config.whitelist.put("testuser", new HueUserAuth("testUserLabel")); - hueService.ds.lights.put(2, new HueDevice(item, "color", DeviceType.ColorType)); - - body = "{'ct':500}"; - c = (HttpURLConnection) new URL(host + "/api/testuser/lights/2/state").openConnection(); - c.setRequestProperty("Content-Type", "application/json"); - c.setRequestMethod("PUT"); - c.setDoOutput(true); - c.getOutputStream().write(body.getBytes(), 0, body.getBytes().length); - assertThat(c.getResponseCode(), is(200)); - body = read(c); - assertThat(body, containsString("success")); - assertThat(body, containsString("sat")); - assertThat(body, containsString("ct")); - - // Saturation is expected to be 0 -> white light - verify(eventPublisher).post(argThat(ce -> assertSatValue((ItemCommandEvent) ce, 0))); - } - - 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; - } - - private boolean assertOnValue(ItemCommandEvent ce, boolean value) { - assertThat(ce.getItemCommand(), is(OnOffType.from(value))); - return true; - } }