]> git.basschouten.com Git - openhab-addons.git/blob
32041414635e348d14e39a9030bc61fa67d9730d
[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.List;
17 import java.util.Set;
18 import java.util.stream.Collectors;
19 import java.util.stream.Stream;
20
21 import javax.ws.rs.DELETE;
22 import javax.ws.rs.GET;
23 import javax.ws.rs.POST;
24 import javax.ws.rs.PUT;
25 import javax.ws.rs.Path;
26 import javax.ws.rs.PathParam;
27 import javax.ws.rs.Produces;
28 import javax.ws.rs.core.Context;
29 import javax.ws.rs.core.MediaType;
30 import javax.ws.rs.core.Response;
31 import javax.ws.rs.core.UriInfo;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.core.common.registry.RegistryChangeListener;
36 import org.openhab.core.events.EventPublisher;
37 import org.openhab.core.items.GenericItem;
38 import org.openhab.core.items.GroupItem;
39 import org.openhab.core.items.Item;
40 import org.openhab.core.items.ItemRegistry;
41 import org.openhab.core.items.events.ItemEventFactory;
42 import org.openhab.core.library.CoreItemFactory;
43 import org.openhab.core.types.Command;
44 import org.openhab.io.hueemulation.internal.ConfigStore;
45 import org.openhab.io.hueemulation.internal.DeviceType;
46 import org.openhab.io.hueemulation.internal.HueEmulationService;
47 import org.openhab.io.hueemulation.internal.NetworkUtils;
48 import org.openhab.io.hueemulation.internal.StateUtils;
49 import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
50 import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
51 import org.openhab.io.hueemulation.internal.dto.HueNewLights;
52 import org.openhab.io.hueemulation.internal.dto.changerequest.HueChangeRequest;
53 import org.openhab.io.hueemulation.internal.dto.changerequest.HueStateChange;
54 import org.openhab.io.hueemulation.internal.dto.response.HueResponse;
55 import org.osgi.service.component.annotations.Activate;
56 import org.osgi.service.component.annotations.Component;
57 import org.osgi.service.component.annotations.Deactivate;
58 import org.osgi.service.component.annotations.Reference;
59 import org.osgi.service.component.annotations.ReferenceCardinality;
60 import org.osgi.service.component.annotations.ReferencePolicy;
61 import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
62 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsApplicationSelect;
63 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
66
67 import com.google.gson.reflect.TypeToken;
68
69 import io.swagger.v3.oas.annotations.Operation;
70 import io.swagger.v3.oas.annotations.Parameter;
71 import io.swagger.v3.oas.annotations.responses.ApiResponse;
72
73 /**
74  * Listens to the ItemRegistry for items that fulfill one of these criteria:
75  * <ul>
76  * <li>Type is any of SWITCH, DIMMER, COLOR, or Group
77  * <li>The category is "ColorLight" for coloured lights or "Light" for switchables.
78  * <li>The item is tagged, according to what is set with {@link #setFilterTags(Set, Set, Set)}.
79  * </ul>
80  *
81  * <p>
82  * A {@link HueLightEntry} instances is created for each found item.
83  * Those are kept in the given {@link org.openhab.io.hueemulation.internal.dto.HueDataStore}.
84  * </p>
85  *
86  * <p>
87  * The HUE Rest API requires a unique string based ID for every listed light.
88  * We are using item names here. Not all hue clients might be compatible with non
89  * numeric Ics.ds. A solution could be an ItemMetaData provider and to store a
90  * generated integer id for each item.
91  * </p>
92  *
93  * <p>
94  * </p>
95  *
96  * @author David Graeff - Initial contribution
97  * @author Florian Schmidt - Removed base type restriction from Group items
98  */
99 @Component(immediate = false, service = LightsAndGroups.class)
100 @JaxrsResource
101 @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + HueEmulationService.REST_APP_NAME + ")")
102 @NonNullByDefault
103 @Path("")
104 @Produces(MediaType.APPLICATION_JSON)
105 public class LightsAndGroups implements RegistryChangeListener<Item> {
106     public static final String EXPOSE_AS_DEVICE_TAG = "huelight";
107     private final Logger logger = LoggerFactory.getLogger(LightsAndGroups.class);
108     private static final String ITEM_TYPE_GROUP = "Group";
109     private static final Set<String> ALLOWED_ITEM_TYPES = Stream.of(CoreItemFactory.COLOR, CoreItemFactory.DIMMER,
110             CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.SWITCH, ITEM_TYPE_GROUP).collect(Collectors.toSet());
111
112     @Reference
113     protected @NonNullByDefault({}) ConfigStore cs;
114     @Reference
115     protected @NonNullByDefault({}) UserManagement userManagement;
116     @Reference
117     protected @NonNullByDefault({}) ItemRegistry itemRegistry;
118     @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
119     protected volatile @Nullable EventPublisher eventPublisher;
120
121     /**
122      * Registers to the {@link ItemRegistry} and enumerates currently existing items.
123      */
124     @Activate
125     protected void activate() {
126         cs.ds.resetGroupsAndLights();
127
128         itemRegistry.removeRegistryChangeListener(this);
129         itemRegistry.addRegistryChangeListener(this);
130
131         for (Item item : itemRegistry.getItems()) {
132             added(item);
133         }
134     }
135
136     /**
137      * Unregisters from the {@link ItemRegistry}.
138      */
139     @Deactivate
140     protected void deactivate() {
141         itemRegistry.removeRegistryChangeListener(this);
142     }
143
144     @Override
145     public synchronized void added(Item newElement) {
146         if (!(newElement instanceof GenericItem)) {
147             return;
148         }
149         GenericItem element = (GenericItem) newElement;
150
151         if (!(element instanceof GroupItem) && !ALLOWED_ITEM_TYPES.contains(element.getType())) {
152             return;
153         }
154
155         DeviceType deviceType = StateUtils.determineTargetType(cs, element);
156         if (deviceType == null) {
157             return;
158         }
159
160         String hueID = cs.mapItemUIDtoHueID(element);
161
162         if (element instanceof GroupItem && !element.hasTag(EXPOSE_AS_DEVICE_TAG)) {
163             GroupItem g = (GroupItem) element;
164             HueGroupEntry group = new HueGroupEntry(g.getName(), g, deviceType);
165
166             // Restore group type and room class from tags
167             for (String tag : g.getTags()) {
168                 if (tag.startsWith("huetype_")) {
169                     group.type = tag.split("huetype_")[1];
170                 } else if (tag.startsWith("hueroom_")) {
171                     group.roomclass = tag.split("hueroom_")[1];
172                 }
173             }
174
175             // Add group members
176             group.lights = new ArrayList<>();
177             for (Item item : g.getMembers()) {
178                 group.lights.add(cs.mapItemUIDtoHueID(item));
179             }
180
181             cs.ds.groups.put(hueID, group);
182         } else {
183             HueLightEntry device = new HueLightEntry(element, cs.getHueUniqueId(hueID), deviceType);
184             device.item = element;
185             cs.ds.lights.put(hueID, device);
186             updateGroup0();
187         }
188     }
189
190     /**
191      * The HUE API enforces a Group 0 that contains all lights.
192      */
193     private void updateGroup0() {
194         cs.ds.groups.get("0").lights = cs.ds.lights.keySet().stream().map(v -> String.valueOf(v))
195                 .collect(Collectors.toList());
196     }
197
198     @Override
199     public synchronized void removed(Item element) {
200         String hueID = cs.mapItemUIDtoHueID(element);
201         logger.debug("Remove item {}", hueID);
202         cs.ds.lights.remove(hueID);
203         cs.ds.groups.remove(hueID);
204         updateGroup0();
205     }
206
207     /**
208      * The tags might have changed
209      */
210     @SuppressWarnings({ "null", "unused" })
211     @Override
212     public synchronized void updated(Item oldElement, Item newElement) {
213         if (!(newElement instanceof GenericItem)) {
214             return;
215         }
216         GenericItem element = (GenericItem) newElement;
217
218         String hueID = cs.mapItemUIDtoHueID(element);
219
220         HueGroupEntry hueGroup = cs.ds.groups.get(hueID);
221         if (hueGroup != null) {
222             DeviceType t = StateUtils.determineTargetType(cs, element);
223             if (t != null && element instanceof GroupItem) {
224                 hueGroup.updateItem((GroupItem) element);
225             } else {
226                 cs.ds.groups.remove(hueID);
227             }
228         }
229
230         HueLightEntry hueDevice = cs.ds.lights.get(hueID);
231         if (hueDevice == null) {
232             // If the correct tags got added -> use the logic within added()
233             added(element);
234             return;
235         }
236
237         // Check if type can still be determined (tags and category is still sufficient)
238         DeviceType t = StateUtils.determineTargetType(cs, element);
239         if (t == null) {
240             removed(element);
241             return;
242         }
243
244         hueDevice.updateItem(element);
245     }
246
247     @GET
248     @Path("{username}/lights")
249     @Operation(summary = "Return all lights", responses = { @ApiResponse(responseCode = "200", description = "OK") })
250     public Response getAllLightsApi(@Context UriInfo uri,
251             @PathParam("username") @Parameter(description = "username") String username) {
252         if (!userManagement.authorizeUser(username)) {
253             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
254         }
255         return Response.ok(cs.gson.toJson(cs.ds.lights)).build();
256     }
257
258     @GET
259     @Path("{username}/lights/new")
260     @Operation(summary = "Return new lights since last scan. Returns an empty list for openHAB as we do not cache that information.", responses = {
261             @ApiResponse(responseCode = "200", description = "OK") })
262     public Response getNewLights(@Context UriInfo uri,
263             @PathParam("username") @Parameter(description = "username") String username) {
264         if (!userManagement.authorizeUser(username)) {
265             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
266         }
267         return Response.ok(cs.gson.toJson(new HueNewLights())).build();
268     }
269
270     @POST
271     @Path("{username}/lights")
272     @Operation(summary = "Starts a new scan for compatible items. This is usually not necessary, because we are observing the item registry.", responses = {
273             @ApiResponse(responseCode = "200", description = "OK") })
274     public Response postNewLights(@Context UriInfo uri,
275             @PathParam("username") @Parameter(description = "username") String username) {
276         if (!userManagement.authorizeUser(username)) {
277             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
278         }
279         return NetworkUtils.singleSuccess(cs.gson, "Searching for new devices", "/lights");
280     }
281
282     @GET
283     @Path("{username}/lights/{id}")
284     @Operation(summary = "Return a light", responses = { @ApiResponse(responseCode = "200", description = "OK") })
285     public Response getLightApi(@Context UriInfo uri, //
286             @PathParam("username") @Parameter(description = "username") String username,
287             @PathParam("id") @Parameter(description = "light id") String id) {
288         if (!userManagement.authorizeUser(username)) {
289             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
290         }
291         return Response.ok(cs.gson.toJson(cs.ds.lights.get(id))).build();
292     }
293
294     @SuppressWarnings({ "null", "unused" })
295     @DELETE
296     @Path("{username}/lights/{id}")
297     @Operation(summary = "Deletes the item that is represented by this id", responses = {
298             @ApiResponse(responseCode = "200", description = "The item got removed"),
299             @ApiResponse(responseCode = "403", description = "Access denied") })
300     public Response removeLightAPI(@Context UriInfo uri,
301             @PathParam("username") @Parameter(description = "username") String username,
302             @PathParam("id") @Parameter(description = "id") String id) {
303         if (!userManagement.authorizeUser(username)) {
304             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
305         }
306
307         HueLightEntry hueDevice = cs.ds.lights.get(id);
308         if (hueDevice == null) {
309             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light does not exist");
310         }
311
312         if (itemRegistry.remove(id) != null) {
313             return NetworkUtils.singleSuccess(cs.gson, "/lights/" + id + " deleted.");
314         } else {
315             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light does not exist");
316         }
317     }
318
319     @SuppressWarnings({ "null", "unused" })
320     @PUT
321     @Path("{username}/lights/{id}")
322     @Operation(summary = "Rename a light", responses = { @ApiResponse(responseCode = "200", description = "OK") })
323     public Response renameLightApi(@Context UriInfo uri, //
324             @PathParam("username") @Parameter(description = "username") String username,
325             @PathParam("id") @Parameter(description = "light id") String id, String body) {
326         if (!userManagement.authorizeUser(username)) {
327             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
328         }
329         HueLightEntry hueDevice = cs.ds.lights.get(id);
330         if (hueDevice == null) {
331             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light not existing");
332         }
333
334         final HueChangeRequest changeRequest = cs.gson.fromJson(body, HueChangeRequest.class);
335
336         String name = changeRequest.name;
337         if (name == null || name.isEmpty()) {
338             return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON, "Invalid request: No name set");
339         }
340
341         hueDevice.item.setLabel(name);
342         itemRegistry.update(hueDevice.item);
343
344         return NetworkUtils.singleSuccess(cs.gson, name, "/lights/" + id + "/name");
345     }
346
347     @SuppressWarnings({ "null", "unused" })
348     @PUT
349     @Path("{username}/lights/{id}/state")
350     @Operation(summary = "Set light state", responses = { @ApiResponse(responseCode = "200", description = "OK") })
351     public Response setLightStateApi(@Context UriInfo uri, //
352             @PathParam("username") @Parameter(description = "username") String username,
353             @PathParam("id") @Parameter(description = "light id") String id, String body) {
354         if (!userManagement.authorizeUser(username)) {
355             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
356         }
357         HueLightEntry hueDevice = cs.ds.lights.get(id);
358         if (hueDevice == null) {
359             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light not existing");
360         }
361
362         HueStateChange newState = cs.gson.fromJson(body, HueStateChange.class);
363         if (newState == null) {
364             return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
365                     "Invalid request: No state change data received!");
366         }
367
368         hueDevice.state = StateUtils.colorStateFromItemState(hueDevice.item.getState(), hueDevice.deviceType);
369
370         String itemUID = hueDevice.item.getUID();
371         List<HueResponse> responses = new ArrayList<>();
372         Command command = StateUtils.computeCommandByState(responses, "/lights/" + id + "/state", hueDevice.state,
373                 newState);
374
375         // If a command could be created, post it to the framework now
376         if (command != null) {
377             EventPublisher localEventPublisher = eventPublisher;
378             if (localEventPublisher != null) {
379                 logger.debug("sending {} to {}", command, itemUID);
380                 localEventPublisher.post(ItemEventFactory.createCommandEvent(itemUID, command, "hueemulation"));
381             } else {
382                 logger.warn("No event publisher. Cannot post item '{}' command!", itemUID);
383             }
384             hueDevice.lastCommand = command;
385             hueDevice.lastHueChange = newState;
386         }
387
388         return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
389         }.getType())).build();
390     }
391
392     @SuppressWarnings({ "null", "unused" })
393     @PUT
394     @Path("{username}/groups/{id}/action")
395     @Operation(summary = "Initiate group action", responses = {
396             @ApiResponse(responseCode = "200", description = "OK") })
397     public Response setGroupActionApi(@Context UriInfo uri, //
398             @PathParam("username") @Parameter(description = "username") String username,
399             @PathParam("id") @Parameter(description = "group id") String id, String body) {
400         if (!userManagement.authorizeUser(username)) {
401             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
402         }
403         HueGroupEntry hueDevice = cs.ds.groups.get(id);
404         GroupItem groupItem = hueDevice.groupItem;
405         if (hueDevice == null || groupItem == null) {
406             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group not existing");
407         }
408
409         HueStateChange state = cs.gson.fromJson(body, HueStateChange.class);
410         if (state == null) {
411             return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
412                     "Invalid request: No state change data received!");
413         }
414
415         // First synchronize the internal state information with the framework
416         hueDevice.action = StateUtils.colorStateFromItemState(groupItem.getState(), hueDevice.deviceType);
417
418         List<HueResponse> responses = new ArrayList<>();
419         Command command = StateUtils.computeCommandByState(responses, "/groups/" + id + "/state/", hueDevice.action,
420                 state);
421
422         // If a command could be created, post it to the framework now
423         if (command != null) {
424             logger.debug("sending {} to {}", command, id);
425             EventPublisher localEventPublisher = eventPublisher;
426             if (localEventPublisher != null) {
427                 localEventPublisher
428                         .post(ItemEventFactory.createCommandEvent(groupItem.getUID(), command, "hueemulation"));
429             } else {
430                 logger.warn("No event publisher. Cannot post item '{}' command!", groupItem.getUID());
431             }
432         }
433
434         return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
435         }.getType())).build();
436     }
437
438     @GET
439     @Path("{username}/groups")
440     @Operation(summary = "Return all groups", responses = { @ApiResponse(responseCode = "200", description = "OK") })
441     public Response getAllGroupsApi(@Context UriInfo uri,
442             @PathParam("username") @Parameter(description = "username") String username) {
443         if (!userManagement.authorizeUser(username)) {
444             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
445         }
446         return Response.ok(cs.gson.toJson(cs.ds.groups)).build();
447     }
448
449     @GET
450     @Path("{username}/groups/{id}")
451     @Operation(summary = "Return a group", responses = { @ApiResponse(responseCode = "200", description = "OK") })
452     public Response getGroupApi(@Context UriInfo uri, //
453             @PathParam("username") @Parameter(description = "username") String username,
454             @PathParam("id") @Parameter(description = "group id") String id) {
455         if (!userManagement.authorizeUser(username)) {
456             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
457         }
458         return Response.ok(cs.gson.toJson(cs.ds.groups.get(id))).build();
459     }
460
461     @SuppressWarnings({ "null", "unused" })
462     @POST
463     @Path("{username}/groups")
464     @Operation(summary = "Create a new group", responses = { @ApiResponse(responseCode = "200", description = "OK") })
465     public Response postNewGroup(@Context UriInfo uri,
466             @PathParam("username") @Parameter(description = "username") String username, String body) {
467         if (!userManagement.authorizeUser(username)) {
468             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
469         }
470
471         HueGroupEntry state = cs.gson.fromJson(body, HueGroupEntry.class);
472         if (state == null || state.name.isEmpty()) {
473             return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
474                     "Invalid request: No state change data received!");
475         }
476
477         String groupid = cs.ds.nextGroupID();
478         GroupItem groupItem = new GroupItem(groupid);
479
480         if (!HueGroupEntry.TypeEnum.LightGroup.name().equals(state.type)) {
481             groupItem.addTag("huetype_" + state.type);
482         }
483
484         if (HueGroupEntry.TypeEnum.Room.name().equals(state.type) && !state.roomclass.isEmpty()) {
485             groupItem.addTag("hueroom_" + state.roomclass);
486         }
487
488         List<Item> groupItems = new ArrayList<>();
489         for (String id : state.lights) {
490             Item item = itemRegistry.get(id);
491             if (item == null) {
492                 logger.debug("Could not create group {}. Item {} not existing!", state.name, id);
493                 return NetworkUtils.singleError(cs.gson, uri, HueResponse.ARGUMENTS_INVALID,
494                         "Invalid request: Item not existing");
495             }
496             groupItem.addMember(item);
497         }
498
499         itemRegistry.add(groupItem);
500
501         return NetworkUtils.singleSuccess(cs.gson, groupid, "id");
502     }
503
504     @SuppressWarnings({ "null", "unused" })
505     @DELETE
506     @Path("{username}/groups/{id}")
507     @Operation(summary = "Deletes the item that is represented by this id", responses = {
508             @ApiResponse(responseCode = "200", description = "The item got removed"),
509             @ApiResponse(responseCode = "403", description = "Access denied") })
510     public Response removeGroupAPI(@Context UriInfo uri,
511             @PathParam("username") @Parameter(description = "username") String username,
512             @PathParam("id") @Parameter(description = "id") String id) {
513         if (!userManagement.authorizeUser(username)) {
514             return NetworkUtils.singleError(cs.gson, uri, HueResponse.UNAUTHORIZED, "Not Authorized");
515         }
516
517         HueLightEntry hueDevice = cs.ds.lights.get(id);
518         if (hueDevice == null) {
519             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");
520         }
521
522         if (itemRegistry.remove(id) != null) {
523             return NetworkUtils.singleSuccess(cs.gson, "/groups/" + id + " deleted.");
524         } else {
525             return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");
526         }
527     }
528 }