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.List;
19 import java.util.Objects;
20 import java.util.UUID;
21 import java.util.stream.Collectors;
23 import javax.ws.rs.Consumes;
24 import javax.ws.rs.DELETE;
25 import javax.ws.rs.GET;
26 import javax.ws.rs.POST;
27 import javax.ws.rs.PUT;
28 import javax.ws.rs.Path;
29 import javax.ws.rs.PathParam;
30 import javax.ws.rs.Produces;
31 import javax.ws.rs.core.Context;
32 import javax.ws.rs.core.MediaType;
33 import javax.ws.rs.core.Response;
34 import javax.ws.rs.core.UriInfo;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.openhab.core.automation.Action;
38 import org.openhab.core.automation.Rule;
39 import org.openhab.core.automation.RuleRegistry;
40 import org.openhab.core.automation.util.ModuleBuilder;
41 import org.openhab.core.automation.util.RuleBuilder;
42 import org.openhab.core.common.registry.RegistryChangeListener;
43 import org.openhab.core.config.core.Configuration;
44 import org.openhab.core.items.GroupItem;
45 import org.openhab.core.items.Item;
46 import org.openhab.core.items.ItemNotFoundException;
47 import org.openhab.core.items.ItemRegistry;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.State;
50 import org.openhab.io.hueemulation.internal.ConfigStore;
51 import org.openhab.io.hueemulation.internal.HueEmulationService;
52 import org.openhab.io.hueemulation.internal.NetworkUtils;
53 import org.openhab.io.hueemulation.internal.StateUtils;
54 import org.openhab.io.hueemulation.internal.automation.dto.ItemCommandActionConfig;
55 import org.openhab.io.hueemulation.internal.dto.AbstractHueState;
56 import org.openhab.io.hueemulation.internal.dto.HueSceneEntry;
57 import org.openhab.io.hueemulation.internal.dto.HueSceneWithLightstates;
58 import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeSceneEntry;
59 import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
60 import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
61 import org.openhab.io.hueemulation.internal.dto.response.HueSuccessGeneric;
62 import org.osgi.service.component.annotations.Activate;
63 import org.osgi.service.component.annotations.Component;
64 import org.osgi.service.component.annotations.Deactivate;
65 import org.osgi.service.component.annotations.Reference;
66 import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
67 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsApplicationSelect;
68 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
72 import io.swagger.v3.oas.annotations.Operation;
73 import io.swagger.v3.oas.annotations.Parameter;
74 import io.swagger.v3.oas.annotations.responses.ApiResponse;
77 * Handles Hue scenes via the automation subsystem and the corresponding REST interface
79 * @author David Graeff - Initial contribution
81 @Component(immediate = false, service = Scenes.class)
83 @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + HueEmulationService.REST_APP_NAME + ")")
86 @Produces(MediaType.APPLICATION_JSON)
87 @Consumes(MediaType.APPLICATION_JSON)
88 public class Scenes implements RegistryChangeListener<Rule> {
89 private final Logger logger = LoggerFactory.getLogger(Scenes.class);
92 protected @NonNullByDefault({}) ConfigStore cs;
94 protected @NonNullByDefault({}) UserManagement userManagement;
96 protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
98 protected @NonNullByDefault({}) ItemRegistry itemRegistry;
101 * Registers to the {@link RuleRegistry} and enumerates currently existing rules.
104 public void activate() {
105 ruleRegistry.removeRegistryChangeListener(this);
106 ruleRegistry.addRegistryChangeListener(this);
108 for (Rule item : ruleRegistry.getAll()) {
114 public void deactivate() {
115 ruleRegistry.removeRegistryChangeListener(this);
119 public void added(Rule scene) {
120 if (!scene.getTags().contains("scene")) {
123 HueSceneEntry entry = new HueSceneEntry(scene.getName());
124 String desc = scene.getDescription();
126 entry.description = desc;
129 List<String> items = new ArrayList<>();
131 for (Action a : scene.getActions()) {
132 if (!a.getTypeUID().equals("core.ItemCommandAction")) {
135 ItemCommandActionConfig config = a.getConfiguration().as(ItemCommandActionConfig.class);
138 item = itemRegistry.getItem(config.itemName);
139 } catch (ItemNotFoundException e) {
140 logger.warn("Rule {} is referring to a non existing item {}", scene.getName(), config.itemName);
143 if (scene.getActions().size() == 1 && item instanceof GroupItem) {
144 entry.type = HueSceneEntry.TypeEnum.GroupScene;
145 entry.group = cs.mapItemUIDtoHueID(item);
147 items.add(cs.mapItemUIDtoHueID(item));
151 if (!items.isEmpty()) {
152 entry.lights = items;
155 cs.ds.scenes.put(scene.getUID(), entry);
159 public void removed(Rule element) {
160 cs.ds.scenes.remove(element.getUID());
164 public void updated(Rule oldElement, Rule element) {
170 @Path("{username}/scenes")
171 @Produces(MediaType.APPLICATION_JSON)
172 @Operation(summary = "Return all scenes", responses = { @ApiResponse(responseCode = "200", description = "OK") })
173 public Response getScenesApi(@Context UriInfo uri,
174 @PathParam("username") @Parameter(description = "username") String username) {
175 if (!userManagement.authorizeUser(username)) {
176 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
178 return Response.ok(cs.gson.toJson(cs.ds.scenes)).build();
181 @SuppressWarnings({ "unused", "null" })
183 @Path("{username}/scenes/{id}")
184 @Operation(summary = "Return a scene", responses = { @ApiResponse(responseCode = "200", description = "OK") })
185 public Response getSceneApi(@Context UriInfo uri, //
186 @PathParam("username") @Parameter(description = "username") String username,
187 @PathParam("id") @Parameter(description = "scene id") String id) {
188 if (!userManagement.authorizeUser(username)) {
189 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
191 HueSceneEntry sceneEntry = cs.ds.scenes.get(id);
192 if (sceneEntry == null) {
193 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
195 HueSceneWithLightstates s = new HueSceneWithLightstates(sceneEntry);
196 for (String itemID : s.lights) {
199 item = itemRegistry.getItem(itemID);
200 } catch (ItemNotFoundException e) {
201 logger.warn("Scene {} is referring to a non existing item {}", sceneEntry.name, itemID);
204 AbstractHueState state = StateUtils.colorStateFromItemState(item.getState(), null);
205 s.lightstates.put(cs.mapItemUIDtoHueID(item), state);
208 return Response.ok(cs.gson.toJson(s)).build();
212 @Path("{username}/scenes/{id}")
213 @Operation(summary = "Deletes a scene", responses = {
214 @ApiResponse(responseCode = "200", description = "The user got removed"),
215 @ApiResponse(responseCode = "403", description = "Access denied") })
216 public Response removeSceneApi(@Context UriInfo uri,
217 @PathParam("username") @Parameter(description = "username") String username,
218 @PathParam("id") @Parameter(description = "Scene to remove") String id) {
219 if (!userManagement.authorizeUser(username)) {
220 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
223 Rule rule = ruleRegistry.remove(id);
225 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
228 return NetworkUtils.singleSuccess(cs.gson, "/scenes/" + id + " deleted.");
231 protected static Action actionFromState(String itemID, State state) {
232 final Configuration actionConfig = new Configuration();
233 actionConfig.put("itemName", itemID);
234 actionConfig.put("command", StateUtils.commandByItemState(state).toFullString());
235 return ModuleBuilder.createAction().withId(itemID).withTypeUID("core.ItemCommandAction")
236 .withConfiguration(actionConfig).build();
239 protected static Action actionFromState(String itemID, Command command) {
240 final Configuration actionConfig = new Configuration();
241 actionConfig.put("itemName", itemID);
242 actionConfig.put("command", command.toFullString());
243 return ModuleBuilder.createAction().withId(itemID).withTypeUID("core.ItemCommandAction")
244 .withConfiguration(actionConfig).build();
248 * Either assigns a new name, description, lights to a scene or directly assign
249 * a new light state for an entry to a scene
252 @Path("{username}/scenes/{id}")
253 @Operation(summary = "Set scene attributes", responses = { @ApiResponse(responseCode = "200", description = "OK") })
254 public Response modifySceneApi(@Context UriInfo uri, //
255 @PathParam("username") @Parameter(description = "username") String username,
256 @PathParam("id") @Parameter(description = "scene id") String id, String body) {
257 if (!userManagement.authorizeUser(username)) {
258 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
261 final HueChangeSceneEntry changeRequest = cs.gson.fromJson(body, HueChangeSceneEntry.class);
263 Rule rule = ruleRegistry.remove(id);
265 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
268 RuleBuilder builder = RuleBuilder.create(rule);
270 String temp = changeRequest.name;
272 builder.withName(temp);
274 temp = changeRequest.description;
276 builder.withDescription(temp);
279 List<String> lights = changeRequest.lights;
280 if (changeRequest.storelightstate && lights != null) {
281 @SuppressWarnings("null")
282 @NonNullByDefault({})
283 List<Action> actions = lights.stream().map(itemID -> itemRegistry.get(itemID)).filter(Objects::nonNull)
284 .map(item -> actionFromState(item.getUID(), item.getState())).collect(Collectors.toList());
285 builder.withActions(actions);
287 Map<String, HueStateChange> lightStates = changeRequest.lightstates;
288 if (changeRequest.storelightstate && lightStates != null) {
289 List<Action> actions = new ArrayList<>(rule.getActions());
290 for (Map.Entry<String, HueStateChange> entry : lightStates.entrySet()) {
291 // Remove existing action
292 actions.removeIf(action -> action.getId().equals(entry.getKey()));
294 Command command = StateUtils.computeCommandByChangeRequest(entry.getValue());
295 if (command == null) {
296 logger.warn("Failed to compute command for {}", body);
297 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Cannot compute command!");
299 actions.add(actionFromState(entry.getKey(), command));
301 builder.withActions(actions);
305 ruleRegistry.add(builder.build());
306 } catch (IllegalStateException e) {
307 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
310 List<String> lightsList = changeRequest.lights;
311 return NetworkUtils.successList(cs.gson, Arrays.asList( //
312 new HueSuccessGeneric(changeRequest.name, "/scenes/" + id + "/name"), //
313 new HueSuccessGeneric(changeRequest.description, "/scenes/" + id + "/description"), //
314 new HueSuccessGeneric(lightsList != null ? String.join(",", lightsList) : null,
315 "/scenes/" + id + "/lights") //
319 @SuppressWarnings({ "null" })
321 @Path("{username}/scenes")
322 @Operation(summary = "Create a new scene", responses = { @ApiResponse(responseCode = "200", description = "OK") })
323 public Response postNewScene(@Context UriInfo uri,
324 @PathParam("username") @Parameter(description = "username") String username, String body) {
325 if (!userManagement.authorizeUser(username)) {
326 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
329 HueSceneEntry newScheduleData = cs.gson.fromJson(body, HueSceneEntry.class);
330 if (newScheduleData == null || newScheduleData.name.isEmpty()) {
331 return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
332 "Invalid request: No name or localtime!");
335 String uid = UUID.randomUUID().toString();
336 RuleBuilder builder = RuleBuilder.create(uid).withName(newScheduleData.name).withTags("scene");
338 if (!newScheduleData.description.isEmpty()) {
339 builder.withDescription(newScheduleData.description);
342 List<String> lights = newScheduleData.lights;
343 if (lights != null) {
344 List<Action> actions = new ArrayList<>();
345 for (String itemID : lights) {
346 Item item = itemRegistry.get(itemID);
350 actions.add(actionFromState(cs.mapItemUIDtoHueID(item), item.getState()));
352 builder.withActions(actions);
354 String groupid = newScheduleData.group;
355 if (groupid != null) {
356 Item groupItem = itemRegistry.get(groupid);
357 if (groupItem == null) {
358 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, "Group does not exist!");
360 List<Action> actions = List.of(actionFromState(cs.mapItemUIDtoHueID(groupItem), groupItem.getState()));
361 builder.withActions(actions);
365 ruleRegistry.add(builder.build());
366 } catch (IllegalStateException e) {
367 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
370 return NetworkUtils.singleSuccess(cs.gson, uid, "id");
374 @Path("{username}/scenes/{id}/lightstates/{lightid}")
375 @Operation(summary = "Set scene attributes", responses = { @ApiResponse(responseCode = "200", description = "OK") })
376 public Response modifySceneLightStateApi(@Context UriInfo uri, //
377 @PathParam("username") @Parameter(description = "username") String username,
378 @PathParam("id") @Parameter(description = "scene id") String id,
379 @PathParam("lightid") @Parameter(description = "light id") String lightid, String body) {
380 if (!userManagement.authorizeUser(username)) {
381 return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
384 final HueStateChange changeRequest = Objects.requireNonNull(cs.gson.fromJson(body, HueStateChange.class));
386 Rule rule = ruleRegistry.remove(id);
388 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
391 RuleBuilder builder = RuleBuilder.create(rule);
393 List<Action> actions = new ArrayList<>(rule.getActions());
394 // Remove existing action
395 actions.removeIf(action -> action.getId().equals(lightid));
397 Command command = StateUtils.computeCommandByChangeRequest(changeRequest);
398 if (command == null) {
399 logger.warn("Failed to compute command for {}", body);
400 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Cannot compute command!");
403 actions.add(actionFromState(lightid, command));
405 builder.withActions(actions);
408 ruleRegistry.add(builder.build());
409 } catch (IllegalStateException e) {
410 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
413 return NetworkUtils.successList(cs.gson, Arrays.asList( //
414 new HueSuccessGeneric(changeRequest.on, "/scenes/" + id + "/lightstates/" + lightid + "/on"), //
415 new HueSuccessGeneric(changeRequest.hue, "/scenes/" + id + "/lightstates/" + lightid + "/hue"), //
416 new HueSuccessGeneric(changeRequest.sat, "/scenes/" + id + "/lightstates/" + lightid + "/sat"), //
417 new HueSuccessGeneric(changeRequest.bri, "/scenes/" + id + "/lightstates/" + lightid + "/bri"), //
418 new HueSuccessGeneric(changeRequest.transitiontime,
419 "/scenes/" + id + "/lightstates/" + lightid + "/transitiontime")));