2 * Copyright (c) 2010-2021 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.neeo.internal.servletservices;
15 import java.beans.PropertyChangeEvent;
16 import java.beans.PropertyChangeListener;
17 import java.io.IOException;
18 import java.util.Map.Entry;
19 import java.util.Objects;
20 import java.util.concurrent.ScheduledExecutorService;
22 import javax.servlet.http.HttpServletRequest;
23 import javax.servlet.http.HttpServletResponse;
24 import javax.ws.rs.client.ClientBuilder;
26 import org.apache.commons.lang.StringUtils;
27 import org.eclipse.jdt.annotation.NonNull;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.core.common.ThreadPoolManager;
31 import org.openhab.core.events.Event;
32 import org.openhab.core.events.EventFilter;
33 import org.openhab.core.items.Item;
34 import org.openhab.core.items.ItemNotFoundException;
35 import org.openhab.core.items.events.ItemCommandEvent;
36 import org.openhab.core.items.events.ItemEventFactory;
37 import org.openhab.core.items.events.ItemStateChangedEvent;
38 import org.openhab.core.library.types.OnOffType;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.ThingUID;
41 import org.openhab.core.thing.events.ChannelTriggeredEvent;
42 import org.openhab.core.thing.events.ThingEventFactory;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.State;
45 import org.openhab.io.neeo.internal.NeeoApi;
46 import org.openhab.io.neeo.internal.NeeoConstants;
47 import org.openhab.io.neeo.internal.NeeoDeviceKeys;
48 import org.openhab.io.neeo.internal.NeeoItemValueConverter;
49 import org.openhab.io.neeo.internal.NeeoUtil;
50 import org.openhab.io.neeo.internal.ServiceContext;
51 import org.openhab.io.neeo.internal.models.ButtonInfo;
52 import org.openhab.io.neeo.internal.models.NeeoButtonGroup;
53 import org.openhab.io.neeo.internal.models.NeeoCapabilityType;
54 import org.openhab.io.neeo.internal.models.NeeoDevice;
55 import org.openhab.io.neeo.internal.models.NeeoDeviceChannel;
56 import org.openhab.io.neeo.internal.models.NeeoDeviceChannelDirectory;
57 import org.openhab.io.neeo.internal.models.NeeoDeviceChannelKind;
58 import org.openhab.io.neeo.internal.models.NeeoDirectoryRequest;
59 import org.openhab.io.neeo.internal.models.NeeoDirectoryRequestAction;
60 import org.openhab.io.neeo.internal.models.NeeoDirectoryResult;
61 import org.openhab.io.neeo.internal.models.NeeoItemValue;
62 import org.openhab.io.neeo.internal.models.NeeoNotification;
63 import org.openhab.io.neeo.internal.models.NeeoSensorNotification;
64 import org.openhab.io.neeo.internal.models.NeeoThingUID;
65 import org.openhab.io.neeo.internal.net.HttpRequest;
66 import org.openhab.io.neeo.internal.servletservices.models.PathInfo;
67 import org.openhab.io.neeo.internal.servletservices.models.ReturnStatus;
68 import org.slf4j.Logger;
69 import org.slf4j.LoggerFactory;
71 import com.google.gson.Gson;
74 * The implementation of {@link ServletService} that will handle device callbacks from the Neeo Brain
76 * @author Tim Roberts - Initial Contribution
79 public class NeeoBrainService extends DefaultServletService {
82 private final Logger logger = LoggerFactory.getLogger(NeeoBrainService.class);
84 /** The gson used for communications */
85 private final Gson gson = NeeoUtil.createGson();
87 /** The NEEO API to use */
88 private final NeeoApi api;
90 /** The service context */
91 private final ServiceContext context;
93 /** The HTTP request */
94 private final HttpRequest request;
96 /** The scheduler to use to schedule recipe execution */
97 private final ScheduledExecutorService scheduler = ThreadPoolManager
98 .getScheduledPool(NeeoConstants.THREAD_POOL_NAME);
100 /** The {@link NeeoItemValueConverter} used to convert values with */
101 private final NeeoItemValueConverter itemConverter;
103 private final PropertyChangeListener listener = new PropertyChangeListener() {
105 public void propertyChange(@Nullable PropertyChangeEvent evt) {
106 if (evt != null && (Boolean) evt.getNewValue()) {
113 * Constructs the service from the {@link NeeoApi} and {@link ServiceContext}
115 * @param api the non-null api
116 * @param context the non-null context
118 public NeeoBrainService(NeeoApi api, ServiceContext context, ClientBuilder clientBuilder) {
119 Objects.requireNonNull(api, "api cannot be null");
120 Objects.requireNonNull(context, "context cannot be null");
122 this.context = context;
123 this.itemConverter = new NeeoItemValueConverter(context);
125 this.api.addPropertyChangeListener(NeeoApi.CONNECTED, listener);
126 scheduler.execute(() -> {
129 request = new HttpRequest(clientBuilder);
133 * Returns true if the path start with 'device' or ends with either 'subscribe' or 'unsubscribe'
135 * @see DefaultServletService#canHandleRoute(String[])
138 public boolean canHandleRoute(String[] paths) {
139 Objects.requireNonNull(paths, "paths cannot be null");
141 if (paths.length == 0) {
145 if (StringUtils.equalsIgnoreCase(paths[0], "device")) {
149 final String lastPath = paths.length >= 2 ? paths[1] : null;
150 return StringUtils.equalsIgnoreCase(lastPath, "subscribe")
151 || StringUtils.equalsIgnoreCase(lastPath, "unsubscribe");
155 public void handlePost(HttpServletRequest req, String[] paths, HttpServletResponse resp) throws IOException {
156 Objects.requireNonNull(req, "req cannot be null");
157 Objects.requireNonNull(paths, "paths cannot be null");
158 Objects.requireNonNull(resp, "resp cannot be null");
159 if (paths.length == 0) {
160 throw new IllegalArgumentException("paths cannot be empty");
163 final boolean hasDeviceStart = StringUtils.equalsIgnoreCase(paths[0], "device");
165 if (hasDeviceStart) {
166 final PathInfo pathInfo = new PathInfo(paths);
168 if (StringUtils.equalsIgnoreCase("directory", pathInfo.getComponentType())) {
169 handleDirectory(req, resp, pathInfo);
171 logger.debug("Unknown/unhandled brain service device route (POST): {}", StringUtils.join(paths, '/'));
175 logger.debug("Unknown/unhandled brain service route (POST): {}", StringUtils.join(paths, '/'));
180 public void handleGet(HttpServletRequest req, String[] paths, HttpServletResponse resp) throws IOException {
181 Objects.requireNonNull(req, "req cannot be null");
182 Objects.requireNonNull(paths, "paths cannot be null");
183 Objects.requireNonNull(resp, "resp cannot be null");
184 if (paths.length == 0) {
185 throw new IllegalArgumentException("paths cannot be empty");
188 // Paths handled specially
189 // 1. See PATHINFO for various /device/* keys (except for the next)
190 // 2. New subscribe path: /device/{thingUID}/subscribe/default/{devicekey}
191 // 3. New unsubscribe path: /device/{thingUID}/unsubscribe/default
192 // 4. Old subscribe path: /{thingUID}/subscribe or unsubscribe/{deviceid}/{devicekey}
193 // 4. Old unsubscribe path: /{thingUID}/subscribe or unsubscribe/{deviceid}
195 final boolean hasDeviceStart = StringUtils.equalsIgnoreCase(paths[0], "device");
196 if (hasDeviceStart && (paths.length >= 3 && !StringUtils.equalsIgnoreCase(paths[2], "subscribe")
197 && !StringUtils.equalsIgnoreCase(paths[2], "unsubscribe"))) {
199 final PathInfo pathInfo = new PathInfo(paths);
201 if (StringUtils.isEmpty(pathInfo.getActionValue())) {
202 handleGetValue(resp, pathInfo);
204 handleSetValue(resp, pathInfo);
206 } catch (IllegalArgumentException e) {
207 logger.debug("Bad path: {} - {}", StringUtils.join(paths), e.getMessage(), e);
210 int idx = hasDeviceStart ? 1 : 0;
212 if (idx + 2 < paths.length) {
213 final String adapterName = paths[idx++];
214 final String action = StringUtils.lowerCase(paths[idx++]);
215 idx++; // deviceId/default - not used
219 if (idx < paths.length) {
220 final String deviceKey = paths[idx++];
221 handleSubscribe(resp, adapterName, deviceKey);
223 logger.debug("No device key set for a subscribe action: {}", StringUtils.join(paths, '/'));
227 handleUnsubscribe(resp, adapterName);
230 logger.debug("Unknown action: {}", action);
234 logger.debug("Unknown/unhandled brain service route (GET): {}", StringUtils.join(paths, '/'));
240 * Handle set value from the path
242 * @param resp the non-null response to write the response to
243 * @param pathInfo the non-null path information
245 private void handleSetValue(HttpServletResponse resp, PathInfo pathInfo) {
246 Objects.requireNonNull(resp, "resp cannot be null");
247 Objects.requireNonNull(pathInfo, "pathInfo cannot be null");
249 logger.debug("handleSetValue {}", pathInfo);
250 final NeeoDevice device = context.getDefinitions().getDevice(pathInfo.getThingUid());
251 if (device != null) {
252 final NeeoDeviceChannel channel = device.getChannel(pathInfo.getItemName(), pathInfo.getSubType(),
253 pathInfo.getChannelNbr());
254 if (channel != null && channel.getKind() == NeeoDeviceChannelKind.TRIGGER) {
255 final ChannelTriggeredEvent event = ThingEventFactory.createTriggerEvent(channel.getValue(),
256 new ChannelUID(device.getUid(), channel.getItemName()));
257 logger.debug("Posting triggered event: {}", event);
258 context.getEventPublisher().post(event);
261 final Item item = context.getItemRegistry().getItem(pathInfo.getItemName());
262 final Command cmd = NeeoItemValueConverter.convert(item, pathInfo);
264 final ItemCommandEvent event = ItemEventFactory.createCommandEvent(item.getName(), cmd);
265 logger.debug("Posting item event: {}", event);
266 context.getEventPublisher().post(event);
268 logger.debug("Cannot set value - no command for path: {}", pathInfo);
270 } catch (ItemNotFoundException e) {
271 logger.debug("Cannot set value - no linked items: {}", pathInfo);
275 logger.debug("Cannot set value - no device definition: {}", pathInfo);
280 * Handle set value from the path
282 * @param resp the non-null response to write the response to
283 * @param pathInfo the non-null path information
284 * @throws IOException Signals that an I/O exception has occurred.
286 private void handleGetValue(HttpServletResponse resp, PathInfo pathInfo) throws IOException {
287 Objects.requireNonNull(resp, "resp cannot be null");
288 Objects.requireNonNull(pathInfo, "pathInfo cannot be null");
290 NeeoItemValue niv = new NeeoItemValue("");
293 final NeeoDevice device = context.getDefinitions().getDevice(pathInfo.getThingUid());
294 if (device != null) {
295 final NeeoDeviceChannel channel = device.getChannel(pathInfo.getItemName(), pathInfo.getSubType(),
296 pathInfo.getChannelNbr());
297 if (channel != null && channel.getKind() == NeeoDeviceChannelKind.ITEM) {
299 final Item item = context.getItemRegistry().getItem(pathInfo.getItemName());
300 niv = itemConverter.convert(channel, item.getState());
301 } catch (ItemNotFoundException e) {
302 logger.debug("Item '{}' not found to get a value ({})", pathInfo.getItemName(), pathInfo);
305 logger.debug("Channel definition for '{}' not found to get a value ({})", pathInfo.getItemName(),
309 logger.debug("Device definition for '{}' not found to get a value ({})", pathInfo.getItemName(),
313 NeeoUtil.write(resp, gson.toJson(niv));
315 logger.debug("handleGetValue {}: {}", pathInfo, niv.getValue());
320 * Handle unsubscribing from a device by removing all device keys for the related {@link ThingUID}
322 * @param resp the non-null response to write to
323 * @param adapterName the non-empty adapter name
324 * @throws IOException Signals that an I/O exception has occurred.
326 private void handleUnsubscribe(HttpServletResponse resp, String adapterName) throws IOException {
327 Objects.requireNonNull(resp, "resp cannot be null");
328 NeeoUtil.requireNotEmpty(adapterName, "adapterName cannot be empty");
330 logger.debug("handleUnsubscribe {}", adapterName);
333 final NeeoThingUID uid = new NeeoThingUID(adapterName);
334 api.getDeviceKeys().remove(uid);
335 NeeoUtil.write(resp, gson.toJson(ReturnStatus.SUCCESS));
336 } catch (IllegalArgumentException e) {
337 logger.debug("AdapterName {} is not a valid thinguid - ignoring", adapterName);
338 NeeoUtil.write(resp, gson.toJson(new ReturnStatus("AdapterName not a valid ThingUID: " + adapterName)));
343 * Handle subscribe to a device by adding the device key to the API for the related {@link ThingUID}
345 * @param resp the non-null response to write to
346 * @param adapterName the non-empty adapter name
347 * @param deviceKey the non-empty device key
348 * @throws IOException Signals that an I/O exception has occurred.
350 private void handleSubscribe(HttpServletResponse resp, String adapterName, String deviceKey) throws IOException {
351 Objects.requireNonNull(resp, "resp cannot be null");
352 NeeoUtil.requireNotEmpty(adapterName, "adapterName cannot be empty");
353 NeeoUtil.requireNotEmpty(deviceKey, "deviceKey cannot be empty");
355 logger.debug("handleSubscribe {}/{}", adapterName, deviceKey);
358 final NeeoThingUID uid = new NeeoThingUID(adapterName);
359 api.getDeviceKeys().put(uid, deviceKey);
360 NeeoUtil.write(resp, gson.toJson(ReturnStatus.SUCCESS));
361 } catch (IllegalArgumentException e) {
362 logger.debug("AdapterName {} is not a valid thinguid - ignoring", adapterName);
363 NeeoUtil.write(resp, gson.toJson(new ReturnStatus("AdapterName not a valid ThingUID: " + adapterName)));
368 * Handle a directory request
370 * @param req the non-null request to use
371 * @param resp the non-null response to write to
372 * @param pathInfo the non-null path information
373 * @throws IOException Signals that an I/O exception has occurred.
375 private void handleDirectory(HttpServletRequest req, HttpServletResponse resp, PathInfo pathInfo)
377 Objects.requireNonNull(req, "req cannot be null");
378 Objects.requireNonNull(resp, "resp cannot be null");
379 Objects.requireNonNull(pathInfo, "pathInfo cannot be null");
381 logger.debug("handleDirectory {}", pathInfo);
383 final NeeoDevice device = context.getDefinitions().getDevice(pathInfo.getThingUid());
384 if (device != null) {
385 final NeeoDeviceChannel channel = device.getChannel(pathInfo.getItemName(), pathInfo.getSubType(),
386 pathInfo.getChannelNbr());
387 if (StringUtils.equalsIgnoreCase("action", pathInfo.getActionValue())) {
388 final NeeoDirectoryRequestAction discoveryAction = gson.fromJson(req.getReader(),
389 NeeoDirectoryRequestAction.class);
392 final Item item = context.getItemRegistry().getItem(pathInfo.getItemName());
393 final Command cmd = NeeoItemValueConverter.convert(item, pathInfo,
394 discoveryAction.getActionIdentifier());
396 final ItemCommandEvent event = ItemEventFactory.createCommandEvent(item.getName(), cmd);
397 logger.debug("Posting item event: {}", event);
398 context.getEventPublisher().post(event);
400 logger.debug("Cannot set value (directory) - no command for path: {}", pathInfo);
402 } catch (ItemNotFoundException e) {
403 logger.debug("Cannot set value(directory) - no linked items: {}", pathInfo);
407 if (channel instanceof NeeoDeviceChannelDirectory) {
408 final NeeoDirectoryRequest discoveryRequest = gson.fromJson(req.getReader(),
409 NeeoDirectoryRequest.class);
410 final NeeoDeviceChannelDirectory directoryChannel = (NeeoDeviceChannelDirectory) channel;
411 NeeoUtil.write(resp, gson.toJson(new NeeoDirectoryResult(discoveryRequest, directoryChannel)));
413 logger.debug("Channel definition for '{}' not found to directory set value ({})",
414 pathInfo.getItemName(), pathInfo);
418 logger.debug("Device definition for '{}' not found to directory set value ({})", pathInfo.getItemName(),
425 * Returns the {@link EventFilter} used by this service. The {@link EventFilter} will simply filter for those items
426 * that have been bound
428 * @return a non-null {@link EventFilter}
432 public EventFilter getEventFilter() {
433 return new EventFilter() {
436 public boolean apply(@Nullable Event event) {
437 Objects.requireNonNull(event, "event cannot be null");
439 final ItemStateChangedEvent ise = (ItemStateChangedEvent) event;
440 final String itemName = ise.getItemName();
442 final NeeoDeviceKeys keys = api.getDeviceKeys();
443 final boolean isBound = context.getDefinitions().isBound(keys, itemName);
444 logger.trace("Apply Event: {} --- {} --- {} = {}", event, itemName, isBound, keys);
451 * Handles the event by notifying the NEEO brain of the new value. If the channel has been linked to the
452 * {@link NeeoButtonGroup#POWERONOFF}, then the related recipe will be powered on/off (in addition to sending the
455 * @see DefaultServletService#handleEvent(Event)
459 public boolean handleEvent(Event event) {
460 Objects.requireNonNull(event, "event cannot be null");
462 final ItemStateChangedEvent ise = (ItemStateChangedEvent) event;
463 final String itemName = ise.getItemName();
465 logger.trace("handleEvent: {}", event);
466 notifyState(itemName, ise.getItemState());
472 * Helper function to send the current state of all bound channels
474 private void resendState() {
475 for (final Entry<NeeoDevice, NeeoDeviceChannel> boundEntry : context.getDefinitions()
476 .getBound(api.getDeviceKeys())) {
478 final NeeoDevice device = boundEntry.getKey();
479 final NeeoDeviceChannel channel = boundEntry.getValue();
482 final State state = context.getItemRegistry().getItem(channel.getItemName()).getState();
484 for (String deviceKey : api.getDeviceKeys().get(device.getUid())) {
485 sendNotification(channel, deviceKey, state);
487 } catch (ItemNotFoundException e) {
488 logger.debug("Item not found {}", channel.getItemName());
494 * Helper function to send some state for an itemName to the brain
496 * @param itemName a non-null, non-empty item name
497 * @param state a non-null state
499 private void notifyState(String itemName, State state) {
500 NeeoUtil.requireNotEmpty(itemName, "itemName cannot be empty");
501 Objects.requireNonNull(state, "state cannot be null");
503 logger.trace("notifyState: {} --- {}", itemName, state);
505 for (final Entry<NeeoDevice, NeeoDeviceChannel> boundEntry : context.getDefinitions()
506 .getBound(api.getDeviceKeys(), itemName)) {
507 final NeeoDevice device = boundEntry.getKey();
508 final NeeoDeviceChannel channel = boundEntry.getValue();
509 final NeeoThingUID uid = new NeeoThingUID(device.getUid());
511 logger.trace("notifyState (device): {} --- {} ", uid, channel);
512 for (String deviceKey : api.getDeviceKeys().get(uid)) {
513 logger.trace("notifyState (key): {} --- {}", uid, deviceKey);
515 if (state instanceof OnOffType) {
516 Boolean recipeState = null;
517 final String label = channel.getLabel();
518 if (StringUtils.equalsIgnoreCase(NeeoButtonGroup.POWERONOFF.getText(), label)) {
519 recipeState = state == OnOffType.ON;
520 } else if (state == OnOffType.ON
521 && StringUtils.equalsIgnoreCase(ButtonInfo.POWERON.getLabel(), label)) {
523 } else if (state == OnOffType.OFF
524 && StringUtils.equalsIgnoreCase(ButtonInfo.POWEROFF.getLabel(), label)) {
528 if (recipeState != null) {
529 logger.trace("notifyState (executeRecipe): {} --- {} --- {}", uid, deviceKey, recipeState);
530 final boolean turnOn = recipeState;
531 scheduler.submit(() -> {
533 api.executeRecipe(deviceKey, turnOn);
534 } catch (IOException e) {
535 logger.debug("Exception occurred while handling executing a recipe: {}", e.getMessage(),
542 sendNotification(channel, deviceKey, state);
548 * Helper method to send a notification
550 * @param channel a non-null channel
551 * @param deviceKey a non-null, non-empty device id
552 * @param state a non-null state
554 private void sendNotification(NeeoDeviceChannel channel, String deviceKey, State state) {
555 Objects.requireNonNull(channel, "channel cannot be null");
556 NeeoUtil.requireNotEmpty(deviceKey, "deviceKey cannot be empty");
557 Objects.requireNonNull(state, "state cannot be null");
559 scheduler.execute(() -> {
560 final String uin = channel.getUniqueItemName();
562 final NeeoItemValue niv = itemConverter.convert(channel, state);
564 // Use sensor notification if we have a >= 0.50 firmware AND it's not a power sensor
565 if (api.getSystemInfo().isFirmwareGreaterOrEqual(NeeoConstants.NEEO_FIRMWARE_0_51_1)
566 && channel.getType() != NeeoCapabilityType.SENSOR_POWER) {
567 final NeeoSensorNotification notify = new NeeoSensorNotification(deviceKey, uin, niv.getValue());
569 api.notify(gson.toJson(notify));
570 } catch (IOException e) {
571 logger.debug("Exception occurred while handling event: {}", e.getMessage(), e);
574 final NeeoNotification notify = new NeeoNotification(deviceKey, uin, niv.getValue());
576 api.notify(gson.toJson(notify));
577 } catch (IOException e) {
578 logger.debug("Exception occurred while handling event: {}", e.getMessage(), e);
585 * Simply closes the {@link #request}
587 * @see DefaultServletService#close()
590 public void close() {
591 this.api.removePropertyChangeListener(listener);