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.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.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;
67 import com.google.gson.reflect.TypeToken;
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;
74 * Listens to the ItemRegistry for items that fulfill one of these criteria:
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)}.
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}.
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.
96 * @author David Graeff - Initial contribution
97 * @author Florian Schmidt - Removed base type restriction from Group items
99 @Component(immediate = false, service = LightsAndGroups.class)
101 @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + HueEmulationService.REST_APP_NAME + ")")
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());
113 protected @NonNullByDefault({}) ConfigStore cs;
115 protected @NonNullByDefault({}) UserManagement userManagement;
117 protected @NonNullByDefault({}) ItemRegistry itemRegistry;
118 @Reference(policy = ReferencePolicy.DYNAMIC, cardinality = ReferenceCardinality.OPTIONAL)
119 protected volatile @Nullable EventPublisher eventPublisher;
122 * Registers to the {@link ItemRegistry} and enumerates currently existing items.
125 protected void activate() {
126 cs.ds.resetGroupsAndLights();
128 itemRegistry.removeRegistryChangeListener(this);
129 itemRegistry.addRegistryChangeListener(this);
131 for (Item item : itemRegistry.getItems()) {
137 * Unregisters from the {@link ItemRegistry}.
140 protected void deactivate() {
141 itemRegistry.removeRegistryChangeListener(this);
145 public synchronized void added(Item newElement) {
146 if (!(newElement instanceof GenericItem)) {
149 GenericItem element = (GenericItem) newElement;
151 if (!(element instanceof GroupItem) && !ALLOWED_ITEM_TYPES.contains(element.getType())) {
155 DeviceType deviceType = StateUtils.determineTargetType(cs, element);
156 if (deviceType == null) {
160 String hueID = cs.mapItemUIDtoHueID(element);
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);
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];
176 group.lights = new ArrayList<>();
177 for (Item item : g.getMembers()) {
178 group.lights.add(cs.mapItemUIDtoHueID(item));
181 cs.ds.groups.put(hueID, group);
183 HueLightEntry device = new HueLightEntry(element, cs.getHueUniqueId(hueID), deviceType);
184 device.item = element;
185 cs.ds.lights.put(hueID, device);
191 * The HUE API enforces a Group 0 that contains all lights.
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());
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);
208 * The tags might have changed
210 @SuppressWarnings({ "null", "unused" })
212 public synchronized void updated(Item oldElement, Item newElement) {
213 if (!(newElement instanceof GenericItem)) {
216 GenericItem element = (GenericItem) newElement;
218 String hueID = cs.mapItemUIDtoHueID(element);
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);
226 cs.ds.groups.remove(hueID);
230 HueLightEntry hueDevice = cs.ds.lights.get(hueID);
231 if (hueDevice == null) {
232 // If the correct tags got added -> use the logic within added()
237 // Check if type can still be determined (tags and category is still sufficient)
238 DeviceType t = StateUtils.determineTargetType(cs, element);
244 hueDevice.updateItem(element);
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");
255 return Response.ok(cs.gson.toJson(cs.ds.lights)).build();
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");
267 return Response.ok(cs.gson.toJson(new HueNewLights())).build();
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");
279 return NetworkUtils.singleSuccess(cs.gson, "Searching for new devices", "/lights");
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");
291 return Response.ok(cs.gson.toJson(cs.ds.lights.get(id))).build();
294 @SuppressWarnings({ "null", "unused" })
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");
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");
312 if (itemRegistry.remove(id) != null) {
313 return NetworkUtils.singleSuccess(cs.gson, "/lights/" + id + " deleted.");
315 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Light does not exist");
319 @SuppressWarnings({ "null", "unused" })
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");
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");
334 final HueChangeRequest changeRequest = cs.gson.fromJson(body, HueChangeRequest.class);
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");
341 hueDevice.item.setLabel(name);
342 itemRegistry.update(hueDevice.item);
344 return NetworkUtils.singleSuccess(cs.gson, name, "/lights/" + id + "/name");
347 @SuppressWarnings({ "null", "unused" })
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");
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");
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!");
368 hueDevice.state = StateUtils.colorStateFromItemState(hueDevice.item.getState(), hueDevice.deviceType);
370 String itemUID = hueDevice.item.getUID();
371 List<HueResponse> responses = new ArrayList<>();
372 Command command = StateUtils.computeCommandByState(responses, "/lights/" + id + "/state", hueDevice.state,
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"));
382 logger.warn("No event publisher. Cannot post item '{}' command!", itemUID);
384 hueDevice.lastCommand = command;
385 hueDevice.lastHueChange = newState;
388 return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
389 }.getType())).build();
392 @SuppressWarnings({ "null", "unused" })
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");
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");
409 HueStateChange state = cs.gson.fromJson(body, HueStateChange.class);
411 return NetworkUtils.singleError(cs.gson, uri, HueResponse.INVALID_JSON,
412 "Invalid request: No state change data received!");
415 // First synchronize the internal state information with the framework
416 hueDevice.action = StateUtils.colorStateFromItemState(groupItem.getState(), hueDevice.deviceType);
418 List<HueResponse> responses = new ArrayList<>();
419 Command command = StateUtils.computeCommandByState(responses, "/groups/" + id + "/state/", hueDevice.action,
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) {
428 .post(ItemEventFactory.createCommandEvent(groupItem.getUID(), command, "hueemulation"));
430 logger.warn("No event publisher. Cannot post item '{}' command!", groupItem.getUID());
434 return Response.ok(cs.gson.toJson(responses, new TypeToken<List<?>>() {
435 }.getType())).build();
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");
446 return Response.ok(cs.gson.toJson(cs.ds.groups)).build();
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");
458 return Response.ok(cs.gson.toJson(cs.ds.groups.get(id))).build();
461 @SuppressWarnings({ "null", "unused" })
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");
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!");
477 String groupid = cs.ds.nextGroupID();
478 GroupItem groupItem = new GroupItem(groupid);
480 if (!HueGroupEntry.TypeEnum.LightGroup.name().equals(state.type)) {
481 groupItem.addTag("huetype_" + state.type);
484 if (HueGroupEntry.TypeEnum.Room.name().equals(state.type) && !state.roomclass.isEmpty()) {
485 groupItem.addTag("hueroom_" + state.roomclass);
488 List<Item> groupItems = new ArrayList<>();
489 for (String id : state.lights) {
490 Item item = itemRegistry.get(id);
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");
496 groupItem.addMember(item);
499 itemRegistry.add(groupItem);
501 return NetworkUtils.singleSuccess(cs.gson, groupid, "id");
504 @SuppressWarnings({ "null", "unused" })
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");
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");
522 if (itemRegistry.remove(id) != null) {
523 return NetworkUtils.singleSuccess(cs.gson, "/groups/" + id + " deleted.");
525 return NetworkUtils.singleError(cs.gson, uri, HueResponse.NOT_AVAILABLE, "Group does not exist");