]> git.basschouten.com Git - openhab-addons.git/blob
f02e32483994b17781b1d51fcf0918d773aea13f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.io.hueemulation.internal.rest;
14
15 import java.util.ArrayList;
16 import java.util.Arrays;
17 import java.util.List;
18 import java.util.Map;
19 import java.util.Objects;
20 import java.util.UUID;
21 import java.util.stream.Collectors;
22
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;
35
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;
71
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;
75
76 /**
77  * Handles Hue scenes via the automation subsystem and the corresponding REST interface
78  *
79  * @author David Graeff - Initial contribution
80  */
81 @Component(immediate = false, service = Scenes.class)
82 @JaxrsResource
83 @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + HueEmulationService.REST_APP_NAME + ")")
84 @NonNullByDefault
85 @Path("")
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);
90
91     @Reference
92     protected @NonNullByDefault({}) ConfigStore cs;
93     @Reference
94     protected @NonNullByDefault({}) UserManagement userManagement;
95     @Reference
96     protected @NonNullByDefault({}) RuleRegistry ruleRegistry;
97     @Reference
98     protected @NonNullByDefault({}) ItemRegistry itemRegistry;
99
100     /**
101      * Registers to the {@link RuleRegistry} and enumerates currently existing rules.
102      */
103     @Activate
104     public void activate() {
105         ruleRegistry.removeRegistryChangeListener(this);
106         ruleRegistry.addRegistryChangeListener(this);
107
108         for (Rule item : ruleRegistry.getAll()) {
109             added(item);
110         }
111     }
112
113     @Deactivate
114     public void deactivate() {
115         ruleRegistry.removeRegistryChangeListener(this);
116     }
117
118     @Override
119     public void added(Rule scene) {
120         if (!scene.getTags().contains("scene")) {
121             return;
122         }
123         HueSceneEntry entry = new HueSceneEntry(scene.getName());
124         String desc = scene.getDescription();
125         if (desc != null) {
126             entry.description = desc;
127         }
128
129         List<String> items = new ArrayList<>();
130
131         for (Action a : scene.getActions()) {
132             if (!"core.ItemCommandAction".equals(a.getTypeUID())) {
133                 continue;
134             }
135             ItemCommandActionConfig config = a.getConfiguration().as(ItemCommandActionConfig.class);
136             Item item;
137             try {
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);
141                 continue;
142             }
143             if (scene.getActions().size() == 1 && item instanceof GroupItem) {
144                 entry.type = HueSceneEntry.TypeEnum.GroupScene;
145                 entry.group = cs.mapItemUIDtoHueID(item);
146             } else {
147                 items.add(cs.mapItemUIDtoHueID(item));
148             }
149         }
150
151         if (!items.isEmpty()) {
152             entry.lights = items;
153         }
154
155         cs.ds.scenes.put(scene.getUID(), entry);
156     }
157
158     @Override
159     public void removed(Rule element) {
160         cs.ds.scenes.remove(element.getUID());
161     }
162
163     @Override
164     public void updated(Rule oldElement, Rule element) {
165         removed(oldElement);
166         added(element);
167     }
168
169     @GET
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");
177         }
178         return Response.ok(cs.gson.toJson(cs.ds.scenes)).build();
179     }
180
181     @SuppressWarnings({ "unused", "null" })
182     @GET
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");
190         }
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!");
194         }
195         HueSceneWithLightstates s = new HueSceneWithLightstates(sceneEntry);
196         for (String itemID : s.lights) {
197             Item item;
198             try {
199                 item = itemRegistry.getItem(itemID);
200             } catch (ItemNotFoundException e) {
201                 logger.warn("Scene {} is referring to a non existing item {}", sceneEntry.name, itemID);
202                 continue;
203             }
204             AbstractHueState state = StateUtils.colorStateFromItemState(item.getState(), null);
205             s.lightstates.put(cs.mapItemUIDtoHueID(item), state);
206         }
207
208         return Response.ok(cs.gson.toJson(s)).build();
209     }
210
211     @DELETE
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");
221         }
222
223         Rule rule = ruleRegistry.remove(id);
224         if (rule == null) {
225             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
226         }
227
228         return NetworkUtils.singleSuccess(cs.gson, "/scenes/" + id + " deleted.");
229     }
230
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();
237     }
238
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();
245     }
246
247     /**
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
250      */
251     @PUT
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");
259         }
260
261         final HueChangeSceneEntry changeRequest = cs.gson.fromJson(body, HueChangeSceneEntry.class);
262
263         Rule rule = ruleRegistry.remove(id);
264         if (rule == null) {
265             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
266         }
267
268         RuleBuilder builder = RuleBuilder.create(rule);
269
270         String temp = changeRequest.name;
271         if (temp != null) {
272             builder.withName(temp);
273         }
274         temp = changeRequest.description;
275         if (temp != null) {
276             builder.withDescription(temp);
277         }
278
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);
286         }
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()));
293                 // Assign new action
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!");
298                 }
299                 actions.add(actionFromState(entry.getKey(), command));
300             }
301             builder.withActions(actions);
302         }
303
304         try {
305             ruleRegistry.add(builder.build());
306         } catch (IllegalStateException e) {
307             return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
308         }
309
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") //
316         ));
317     }
318
319     @SuppressWarnings({ "null" })
320     @POST
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");
327         }
328
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!");
333         }
334
335         String uid = UUID.randomUUID().toString();
336         RuleBuilder builder = RuleBuilder.create(uid).withName(newScheduleData.name).withTags("scene");
337
338         if (!newScheduleData.description.isEmpty()) {
339             builder.withDescription(newScheduleData.description);
340         }
341
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);
347                 if (item == null) {
348                     continue;
349                 }
350                 actions.add(actionFromState(cs.mapItemUIDtoHueID(item), item.getState()));
351             }
352             builder.withActions(actions);
353         }
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!");
359             }
360             List<Action> actions = List.of(actionFromState(cs.mapItemUIDtoHueID(groupItem), groupItem.getState()));
361             builder.withActions(actions);
362         }
363
364         try {
365             ruleRegistry.add(builder.build());
366         } catch (IllegalStateException e) {
367             return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
368         }
369
370         return NetworkUtils.singleSuccess(cs.gson, uid, "id");
371     }
372
373     @PUT
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");
382         }
383
384         final HueStateChange changeRequest = Objects.requireNonNull(cs.gson.fromJson(body, HueStateChange.class));
385
386         Rule rule = ruleRegistry.remove(id);
387         if (rule == null) {
388             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Scene does not exist!");
389         }
390
391         RuleBuilder builder = RuleBuilder.create(rule);
392
393         List<Action> actions = new ArrayList<>(rule.getActions());
394         // Remove existing action
395         actions.removeIf(action -> action.getId().equals(lightid));
396         // Assign new action
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!");
401         }
402
403         actions.add(actionFromState(lightid, command));
404
405         builder.withActions(actions);
406
407         try {
408             ruleRegistry.add(builder.build());
409         } catch (IllegalStateException e) {
410             return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID, e.getMessage());
411         }
412
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")));
420     }
421 }