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