2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.io.hueemulation.internal.rest;
15 import java.util.ArrayList;
16 import java.util.Arrays;
17 import java.util.Collections;
18 import java.util.List;
20 import java.util.Objects;
21 import java.util.UUID;
22 import java.util.stream.Collectors;
24 import javax.ws.rs.Consumes;
25 import javax.ws.rs.DELETE;
26 import javax.ws.rs.GET;
27 import javax.ws.rs.POST;
28 import javax.ws.rs.PUT;
29 import javax.ws.rs.Path;
30 import javax.ws.rs.PathParam;
31 import javax.ws.rs.Produces;
32 import javax.ws.rs.core.Context;
33 import javax.ws.rs.core.MediaType;
34 import javax.ws.rs.core.Response;
35 import javax.ws.rs.core.UriInfo;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.openhab.core.automation.Action;
39 import org.openhab.core.automation.Rule;
40 import org.openhab.core.automation.RuleRegistry;
41 import org.openhab.core.automation.util.ModuleBuilder;
42 import org.openhab.core.automation.util.RuleBuilder;
43 import org.openhab.core.common.registry.RegistryChangeListener;
44 import org.openhab.core.config.core.Configuration;
45 import org.openhab.core.items.GroupItem;
46 import org.openhab.core.items.Item;
47 import org.openhab.core.items.ItemNotFoundException;
48 import org.openhab.core.items.ItemRegistry;
49 import org.openhab.core.types.Command;
50 import org.openhab.core.types.State;
51 import org.openhab.io.hueemulation.internal.ConfigStore;
52 import org.openhab.io.hueemulation.internal.HueEmulationService;
53 import org.openhab.io.hueemulation.internal.NetworkUtils;
54 import org.openhab.io.hueemulation.internal.StateUtils;
55 import org.openhab.io.hueemulation.internal.automation.dto.ItemCommandActionConfig;
56 import org.openhab.io.hueemulation.internal.dto.AbstractHueState;
57 import org.openhab.io.hueemulation.internal.dto.HueSceneEntry;
58 import org.openhab.io.hueemulation.internal.dto.HueSceneWithLightstates;
59 import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeSceneEntry;
60 import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
61 import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
62 import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
63 import org.osgi.service.component.annotations.Activate;
64 import org.osgi.service.component.annotations.Component;
65 import org.osgi.service.component.annotations.Deactivate;
66 import org.osgi.service.component.annotations.Reference;
67 import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
68 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsApplicationSelect;
69 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
70 import org.slf4j.Logger;
71 import org.slf4j.LoggerFactory;
73 import io.swagger.v3.oas.annotations.Operation;
74 import io.swagger.v3.oas.annotations.Parameter;
75 import io.swagger.v3.oas.annotations.responses.ApiResponse;
78 * Handles Hue scenes via the automation subsystem and the corresponding REST interface
80 * @author David Graeff - Initial contribution
82 @Component(immediate = false, service = Scenes.class)
84 @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + HueEmulationService.REST_APP_NAME + ")")
87 @Produces(MediaType.APPLICATION_JSON)
88 @Consumes(MediaType.APPLICATION_JSON)
89 public class Scenes implements RegistryChangeListener<Rule> {
90 private final Logger logger = LoggerFactory.getLogger(Scenes.class);
93 protected @NonNullByDefault({}) ConfigStore cs;
95 protected @NonNullByDefault({}) UserManagement userManagement;
97 protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
99 protected @NonNullByDefault({}) ItemRegistry itemRegistry;
102 * Registers to the {@link RuleRegistry} and enumerates currently existing rules.
105 public void activate() {
106 ruleRegistry.removeRegistryChangeListener(this);
107 ruleRegistry.addRegistryChangeListener(this);
109 for (Rule item : ruleRegistry.getAll()) {
115 public void deactivate() {
116 ruleRegistry.removeRegistryChangeListener(this);
120 public void added(Rule scene) {
121 if (!scene.getTags().contains("scene")) {
124 HueSceneEntry entry = new HueSceneEntry(scene.getName());
125 String desc = scene.getDescription();
127 entry.description = desc;
130 List<String> items = new ArrayList<>();
132 for (Action a : scene.getActions()) {
133 if (!a.getTypeUID().equals("core.ItemCommandAction")) {
136 ItemCommandActionConfig config = a.getConfiguration().as(ItemCommandActionConfig.class);
139 item = itemRegistry.getItem(config.itemName);
140 } catch (ItemNotFoundException e) {
141 logger.warn("Rule {} is referring to a non existing item {}", scene.getName(), config.itemName);
144 if (scene.getActions().size() == 1 && item instanceof GroupItem) {
145 entry.type = HueSceneEntry.TypeEnum.GroupScene;
146 entry.group = cs.mapItemUIDtoHueID(item);
148 items.add(cs.mapItemUIDtoHueID(item));
152 if (!items.isEmpty()) {
153 entry.lights = items;
156 cs.ds.scenes.put(scene.getUID(), entry);
160 public void removed(Rule element) {
161 cs.ds.scenes.remove(element.getUID());
165 public void updated(Rule oldElement, Rule element) {
171 @Path("{username}/scenes")
172 @Produces(MediaType.APPLICATION_JSON)
173 @Operation(summary = "Return all scenes", responses = { @ApiResponse(responseCode = "200", description = "OK") })
174 public Response getScenesApi(@Context UriInfo uri,
175 @PathParam("username") @Parameter(description = "username") String username) {
176 if (!userManagement.authorizeUser(username)) {
177 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
179 return Response.ok(cs.gson.toJson(cs.ds.scenes)).build();
182 @SuppressWarnings({ "unused", "null" })
184 @Path("{username}/scenes/{id}")
185 @Operation(summary = "Return a scene", responses = { @ApiResponse(responseCode = "200", description = "OK") })
186 public Response getSceneApi(@Context UriInfo uri, //
187 @PathParam("username") @Parameter(description = "username") String username,
188 @PathParam("id") @Parameter(description = "scene id") String id) {
189 if (!userManagement.authorizeUser(username)) {
190 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
192 HueSceneEntry sceneEntry = cs.ds.scenes.get(id);
193 if (sceneEntry == null) {
194 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
196 HueSceneWithLightstates s = new HueSceneWithLightstates(sceneEntry);
197 for (String itemID : s.lights) {
200 item = itemRegistry.getItem(itemID);
201 } catch (ItemNotFoundException e) {
202 logger.warn("Scene {} is referring to a non existing item {}", sceneEntry.name, itemID);
205 AbstractHueState state = StateUtils.colorStateFromItemState(item.getState(), null);
206 s.lightstates.put(cs.mapItemUIDtoHueID(item), state);
209 return Response.ok(cs.gson.toJson(s)).build();
213 @Path("{username}/scenes/{id}")
214 @Operation(summary = "Deletes a scene", responses = {
215 @ApiResponse(responseCode = "200", description = "The user got removed"),
216 @ApiResponse(responseCode = "403", description = "Access denied") })
217 public Response removeSceneApi(@Context UriInfo uri,
218 @PathParam("username") @Parameter(description = "username") String username,
219 @PathParam("id") @Parameter(description = "Scene to remove") String id) {
220 if (!userManagement.authorizeUser(username)) {
221 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
224 Rule rule = ruleRegistry.remove(id);
226 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
229 return NetworkUtils.singleSuccess(cs.gson, "/scenes/" + id + " deleted.");
232 protected static Action actionFromState(String itemID, State state) {
233 final Configuration actionConfig = new Configuration();
234 actionConfig.put("itemName", itemID);
235 actionConfig.put("command", StateUtils.commandByItemState(state).toFullString());
236 return ModuleBuilder.createAction().withId(itemID).withTypeUID("core.ItemCommandAction")
237 .withConfiguration(actionConfig).build();
240 protected static Action actionFromState(String itemID, Command command) {
241 final Configuration actionConfig = new Configuration();
242 actionConfig.put("itemName", itemID);
243 actionConfig.put("command", command.toFullString());
244 return ModuleBuilder.createAction().withId(itemID).withTypeUID("core.ItemCommandAction")
245 .withConfiguration(actionConfig).build();
249 * Either assigns a new name, description, lights to a scene or directly assign
250 * a new light state for an entry to a scene
253 @Path("{username}/scenes/{id}")
254 @Operation(summary = "Set scene attributes", responses = { @ApiResponse(responseCode = "200", description = "OK") })
255 public Response modifySceneApi(@Context UriInfo uri, //
256 @PathParam("username") @Parameter(description = "username") String username,
257 @PathParam("id") @Parameter(description = "scene id") String id, String body) {
258 if (!userManagement.authorizeUser(username)) {
259 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
262 final HueChangeSceneEntry changeRequest = cs.gson.fromJson(body, HueChangeSceneEntry.class);
264 Rule rule = ruleRegistry.remove(id);
266 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
269 RuleBuilder builder = RuleBuilder.create(rule);
271 String temp = changeRequest.name;
273 builder.withName(temp);
275 temp = changeRequest.description;
277 builder.withDescription(temp);
280 List<String> lights = changeRequest.lights;
281 if (changeRequest.storelightstate && lights != null) {
282 @SuppressWarnings("null")
283 @NonNullByDefault({})
284 List<Action> actions = lights.stream().map(itemID -> itemRegistry.get(itemID)).filter(Objects::nonNull)
285 .map(item -> actionFromState(item.getUID(), item.getState())).collect(Collectors.toList());
286 builder.withActions(actions);
288 Map<String, HueStateChange> lightStates = changeRequest.lightstates;
289 if (changeRequest.storelightstate && lightStates != null) {
290 List<Action> actions = new ArrayList<>(rule.getActions());
291 for (Map.Entry<String, HueStateChange> entry : lightStates.entrySet()) {
292 // Remove existing action
293 actions.removeIf(action -> action.getId().equals(entry.getKey()));
295 Command command = StateUtils.computeCommandByChangeRequest(entry.getValue());
296 if (command == null) {
297 logger.warn("Failed to compute command for {}", body);
298 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Cannot compute command!");
300 actions.add(actionFromState(entry.getKey(), command));
302 builder.withActions(actions);
306 ruleRegistry.add(builder.build());
307 } catch (IllegalStateException e) {
308 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
311 List<String> lightsList = changeRequest.lights;
312 return NetworkUtils.successList(cs.gson, Arrays.asList( //
313 new HueSuccessGeneric(changeRequest.name, "/scenes/" + id + "/name"), //
314 new HueSuccessGeneric(changeRequest.description, "/scenes/" + id + "/description"), //
315 new HueSuccessGeneric(lightsList != null ? String.join(",", lightsList) : null,
316 "/scenes/" + id + "/lights") //
320 @SuppressWarnings({ "null" })
322 @Path("{username}/scenes")
323 @Operation(summary = "Create a new scene", responses = { @ApiResponse(responseCode = "200", description = "OK") })
324 public Response postNewScene(@Context UriInfo uri,
325 @PathParam("username") @Parameter(description = "username") String username, String body) {
326 if (!userManagement.authorizeUser(username)) {
327 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
330 HueSceneEntry newScheduleData = cs.gson.fromJson(body, HueSceneEntry.class);
331 if (newScheduleData == null || newScheduleData.name.isEmpty()) {
332 return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
333 "Invalid request: No name or localtime!");
336 String uid = UUID.randomUUID().toString();
337 RuleBuilder builder = RuleBuilder.create(uid).withName(newScheduleData.name).withTags("scene");
339 if (!newScheduleData.description.isEmpty()) {
340 builder.withDescription(newScheduleData.description);
343 List<String> lights = newScheduleData.lights;
344 if (lights != null) {
345 List<Action> actions = new ArrayList<>();
346 for (String itemID : lights) {
347 Item item = itemRegistry.get(itemID);
351 actions.add(actionFromState(cs.mapItemUIDtoHueID(item), item.getState()));
353 builder.withActions(actions);
355 String groupid = newScheduleData.group;
356 if (groupid != null) {
357 Item groupItem = itemRegistry.get(groupid);
358 if (groupItem == null) {
359 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, "Group does not exist!");
361 List<Action> actions = Collections
362 .singletonList(actionFromState(cs.mapItemUIDtoHueID(groupItem), groupItem.getState()));
363 builder.withActions(actions);
367 ruleRegistry.add(builder.build());
368 } catch (IllegalStateException e) {
369 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
372 return NetworkUtils.singleSuccess(cs.gson, uid, "id");
376 @Path("{username}/scenes/{id}/lightstates/{lightid}")
377 @Operation(summary = "Set scene attributes", responses = { @ApiResponse(responseCode = "200", description = "OK") })
378 public Response modifySceneLightStateApi(@Context UriInfo uri, //
379 @PathParam("username") @Parameter(description = "username") String username,
380 @PathParam("id") @Parameter(description = "scene id") String id,
381 @PathParam("lightid") @Parameter(description = "light id") String lightid, String body) {
382 if (!userManagement.authorizeUser(username)) {
383 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
386 final HueStateChange changeRequest = Objects.requireNonNull(cs.gson.fromJson(body, HueStateChange.class));
388 Rule rule = ruleRegistry.remove(id);
390 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
393 RuleBuilder builder = RuleBuilder.create(rule);
395 List<Action> actions = new ArrayList<>(rule.getActions());
396 // Remove existing action
397 actions.removeIf(action -> action.getId().equals(lightid));
399 Command command = StateUtils.computeCommandByChangeRequest(changeRequest);
400 if (command == null) {
401 logger.warn("Failed to compute command for {}", body);
402 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Cannot compute command!");
405 actions.add(actionFromState(lightid, command));
407 builder.withActions(actions);
410 ruleRegistry.add(builder.build());
411 } catch (IllegalStateException e) {
412 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
415 return NetworkUtils.successList(cs.gson, Arrays.asList( //
416 new HueSuccessGeneric(changeRequest.on, "/scenes/" + id + "/lightstates/" + lightid + "/on"), //
417 new HueSuccessGeneric(changeRequest.hue, "/scenes/" + id + "/lightstates/" + lightid + "/hue"), //
418 new HueSuccessGeneric(changeRequest.sat, "/scenes/" + id + "/lightstates/" + lightid + "/sat"), //
419 new HueSuccessGeneric(changeRequest.bri, "/scenes/" + id + "/lightstates/" + lightid + "/bri"), //
420 new HueSuccessGeneric(changeRequest.transitiontime,
421 "/scenes/" + id + "/lightstates/" + lightid + "/transitiontime")));