2 * Copyright (c) 2010-2020 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.List;
18 import java.util.stream.Collectors;
19 import java.util.stream.Stream;
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;
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;
63 import com.google.gson.reflect.TypeToken;
65 import io.swagger.annotations.ApiOperation;
66 import io.swagger.annotations.ApiParam;
67 import io.swagger.annotations.ApiResponse;
68 import io.swagger.annotations.ApiResponses;
71 * Listens to the ItemRegistry for items that fulfill one of these criteria:
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)}.
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}.
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.
93 * @author David Graeff - Initial contribution
94 * @author Florian Schmidt - Removed base type restriction from Group items
96 @Component(immediate = false, service = { LightsAndGroups.class }, property = "com.eclipsesource.jaxrs.publish=false")
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());
108 protected @NonNullByDefault({}) ConfigStore cs;
110 protected @NonNullByDefault({}) UserManagement userManagement;
112 protected @NonNullByDefault({}) ItemRegistry itemRegistry;
113 @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
114 protected volatile @Nullable EventPublisher eventPublisher;
117 * Registers to the {@link ItemRegistry} and enumerates currently existing items.
120 protected void activate() {
121 cs.ds.resetGroupsAndLights();
123 itemRegistry.removeRegistryChangeListener(this);
124 itemRegistry.addRegistryChangeListener(this);
126 for (Item item : itemRegistry.getItems()) {
132 * Unregisters from the {@link ItemRegistry}.
135 protected void deactivate() {
136 itemRegistry.removeRegistryChangeListener(this);
140 public synchronized void added(Item newElement) {
141 if (!(newElement instanceof GenericItem)) {
144 GenericItem element = (GenericItem) newElement;
146 if (!(element instanceof GroupItem) && !ALLOWED_ITEM_TYPES.contains(element.getType())) {
150 DeviceType deviceType = StateUtils.determineTargetType(cs, element);
151 if (deviceType == null) {
155 String hueID = cs.mapItemUIDtoHueID(element);
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);
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];
171 group.lights = new ArrayList<>();
172 for (Item item : g.getMembers()) {
173 group.lights.add(cs.mapItemUIDtoHueID(item));
176 cs.ds.groups.put(hueID, group);
178 HueLightEntry device = new HueLightEntry(element, cs.ds.config.uuid + "-" + hueID.toString(), deviceType);
179 device.item = element;
180 cs.ds.lights.put(hueID, device);
186 * The HUE API enforces a Group 0 that contains all lights.
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());
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);
203 * The tags might have changed
205 @SuppressWarnings({ "null", "unused" })
207 public synchronized void updated(Item oldElement, Item newElement) {
208 if (!(newElement instanceof GenericItem)) {
211 GenericItem element = (GenericItem) newElement;
213 String hueID = cs.mapItemUIDtoHueID(element);
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);
221 cs.ds.groups.remove(hueID);
225 HueLightEntry hueDevice = cs.ds.lights.get(hueID);
226 if (hueDevice == null) {
227 // If the correct tags got added -> use the logic within added()
232 // Check if type can still be determined (tags and category is still sufficient)
233 DeviceType t = StateUtils.determineTargetType(cs, element);
239 hueDevice.updateItem(element);
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");
251 return Response.ok(cs.gson.toJson(cs.ds.lights)).build();
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");
263 return Response.ok(cs.gson.toJson(new HueNewLights())).build();
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");
275 return NetworkUtils.singleSuccess(cs.gson, "Searching for new devices", "/lights");
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");
288 return Response.ok(cs.gson.toJson(cs.ds.lights.get(id))).build();
291 @SuppressWarnings({ "null", "unused" })
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");
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");
309 if (itemRegistry.remove(id) != null) {
310 return NetworkUtils.singleSuccess(cs.gson, "/lights/" + id + " deleted.");
312 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light does not exist");
316 @SuppressWarnings({ "null", "unused" })
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");
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");
332 final HueChangeRequest changeRequest = cs.gson.fromJson(body, HueChangeRequest.class);
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");
339 hueDevice.item.setLabel(name);
340 itemRegistry.update(hueDevice.item);
342 return NetworkUtils.singleSuccess(cs.gson, name, "/lights/" + id + "/name");
345 @SuppressWarnings({ "null", "unused" })
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");
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");
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!");
367 hueDevice.state = StateUtils.colorStateFromItemState(hueDevice.item.getState(), hueDevice.deviceType);
369 String itemUID = hueDevice.item.getUID();
370 List<HueResponse> responses = new ArrayList<>();
371 Command command = StateUtils.computeCommandByState(responses, "/lights/" + id + "/state", hueDevice.state,
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"));
381 logger.warn("No event publisher. Cannot post item '{}' command!", itemUID);
383 hueDevice.lastCommand = command;
384 hueDevice.lastHueChange = newState;
387 return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
388 }.getType())).build();
391 @SuppressWarnings({ "null", "unused" })
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");
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");
408 HueStateChange state = cs.gson.fromJson(body, HueStateChange.class);
410 return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
411 "Invalid request: No state change data received!");
414 // First synchronize the internal state information with the framework
415 hueDevice.action = StateUtils.colorStateFromItemState(groupItem.getState(), hueDevice.deviceType);
417 List<HueResponse> responses = new ArrayList<>();
418 Command command = StateUtils.computeCommandByState(responses, "/groups/" + id + "/state/", hueDevice.action,
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) {
427 .post(ItemEventFactory.createCommandEvent(groupItem.getUID(), command, "hueemulation"));
429 logger.warn("No event publisher. Cannot post item '{}' command!", groupItem.getUID());
433 return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
434 }.getType())).build();
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");
446 return Response.ok(cs.gson.toJson(cs.ds.groups)).build();
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");
459 return Response.ok(cs.gson.toJson(cs.ds.groups.get(id))).build();
462 @SuppressWarnings({ "null", "unused" })
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");
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!");
479 String groupid = cs.ds.nextGroupID();
480 GroupItem groupItem = new GroupItem(groupid);
482 if (!HueGroupEntry.TypeEnum.LightGroup.name().equals(state.type)) {
483 groupItem.addTag("huetype_" + state.type);
486 if (HueGroupEntry.TypeEnum.Room.name().equals(state.type) && !state.roomclass.isEmpty()) {
487 groupItem.addTag("hueroom_" + state.roomclass);
490 List<Item> groupItems = new ArrayList<>();
491 for (String id : state.lights) {
492 Item item = itemRegistry.get(id);
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");
498 groupItem.addMember(item);
501 itemRegistry.add(groupItem);
503 return NetworkUtils.singleSuccess(cs.gson, groupid, "id");
506 @SuppressWarnings({ "null", "unused" })
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");
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");
524 if (itemRegistry.remove(id) != null) {
525 return NetworkUtils.singleSuccess(cs.gson, "/groups/" + id + " deleted.");
527 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");