2 * Copyright (c) 2010-2022 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.binding.doorbird.internal.handler;
15 import static org.openhab.binding.doorbird.internal.DoorbirdBindingConstants.*;
17 import java.awt.Graphics2D;
18 import java.awt.image.BufferedImage;
19 import java.io.ByteArrayInputStream;
20 import java.io.ByteArrayOutputStream;
21 import java.io.IOException;
22 import java.time.Instant;
23 import java.util.ArrayList;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.concurrent.ScheduledExecutorService;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.function.Function;
31 import javax.imageio.ImageIO;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.openhab.binding.doorbird.internal.action.DoorbirdActions;
37 import org.openhab.binding.doorbird.internal.api.DoorbirdAPI;
38 import org.openhab.binding.doorbird.internal.api.DoorbirdImage;
39 import org.openhab.binding.doorbird.internal.api.SipStatus;
40 import org.openhab.binding.doorbird.internal.config.DoorbellConfiguration;
41 import org.openhab.binding.doorbird.internal.listener.DoorbirdUdpListener;
42 import org.openhab.core.common.ThreadPoolManager;
43 import org.openhab.core.i18n.TimeZoneProvider;
44 import org.openhab.core.library.types.DateTimeType;
45 import org.openhab.core.library.types.DecimalType;
46 import org.openhab.core.library.types.OnOffType;
47 import org.openhab.core.library.types.RawType;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.CommonTriggerEvents;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.thing.binding.ThingHandlerService;
55 import org.openhab.core.types.Command;
56 import org.openhab.core.types.RefreshType;
57 import org.openhab.core.types.State;
58 import org.openhab.core.types.UnDefType;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
63 * The {@link DoorbellHandler} is responsible for handling commands, which are
64 * sent to one of the channels.
66 * @author Mark Hilbush - Initial contribution
69 public class DoorbellHandler extends BaseThingHandler {
70 private static final long MONTAGE_UPDATE_DELAY_SECONDS = 5L;
72 // Maximum number of doorbell and motion history images stored on Doorbird backend
73 private static final int MAX_HISTORY_IMAGES = 50;
75 private final Logger logger = LoggerFactory.getLogger(DoorbellHandler.class);
77 // Get a dedicated threadpool for the long-running listener thread
78 private final ScheduledExecutorService doorbirdScheduler = ThreadPoolManager
79 .getScheduledPool("doorbirdListener" + "-" + thing.getUID().getId());
80 private @Nullable ScheduledFuture<?> listenerJob;
81 private final DoorbirdUdpListener udpListener;
83 private @Nullable ScheduledFuture<?> imageRefreshJob;
84 private @Nullable ScheduledFuture<?> doorbellOffJob;
85 private @Nullable ScheduledFuture<?> motionOffJob;
87 private @NonNullByDefault({}) DoorbellConfiguration config;
89 private DoorbirdAPI api = new DoorbirdAPI();
91 private final TimeZoneProvider timeZoneProvider;
92 private final HttpClient httpClient;
94 public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient) {
96 this.timeZoneProvider = timeZoneProvider;
97 this.httpClient = httpClient;
98 udpListener = new DoorbirdUdpListener(this);
102 public void initialize() {
103 config = getConfigAs(DoorbellConfiguration.class);
104 String host = config.doorbirdHost;
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Doorbird host not provided");
109 String user = config.userId;
111 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User ID not provided");
114 String password = config.userPassword;
115 if (password == null) {
116 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User password not provided");
119 api.setAuthorization(host, user, password);
120 api.setHttpClient(httpClient);
121 startImageRefreshJob();
122 startUDPListenerJob();
123 updateStatus(ThingStatus.ONLINE);
127 public void dispose() {
128 stopUDPListenerJob();
129 stopImageRefreshJob();
130 stopDoorbellOffJob();
135 // Callback used by listener to get Doorbird host name
136 public @Nullable String getDoorbirdHost() {
137 return config.doorbirdHost;
140 // Callback used by listener to get Doorbird password
141 public @Nullable String getUserId() {
142 return config.userId;
145 // Callback used by listener to get Doorbird password
146 public @Nullable String getUserPassword() {
147 return config.userPassword;
150 // Callback used by listener to update doorbell channel
151 public void updateDoorbellChannel(long timestamp) {
152 logger.debug("Handler: Update DOORBELL channels for thing {}", getThing().getUID());
153 DoorbirdImage dbImage = api.downloadCurrentImage();
154 if (dbImage != null) {
155 RawType image = dbImage.getImage();
156 updateState(CHANNEL_DOORBELL_IMAGE, image != null ? image : UnDefType.UNDEF);
157 updateState(CHANNEL_DOORBELL_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
159 triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.PRESSED);
160 startDoorbellOffJob();
161 updateDoorbellMontage();
164 // Callback used by listener to update motion channel
165 public void updateMotionChannel(long timestamp) {
166 logger.debug("Handler: Update MOTION channels for thing {}", getThing().getUID());
167 DoorbirdImage dbImage = api.downloadCurrentImage();
168 if (dbImage != null) {
169 RawType image = dbImage.getImage();
170 updateState(CHANNEL_MOTION_IMAGE, image != null ? image : UnDefType.UNDEF);
171 updateState(CHANNEL_MOTION_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
173 updateState(CHANNEL_MOTION, OnOffType.ON);
175 updateMotionMontage();
179 public void handleCommand(ChannelUID channelUID, Command command) {
180 logger.debug("Got command {} for channel {} of thing {}", command, channelUID, getThing().getUID());
182 switch (channelUID.getId()) {
183 case CHANNEL_DOORBELL_IMAGE:
184 if (command instanceof RefreshType) {
185 refreshDoorbellImageFromHistory();
188 case CHANNEL_MOTION_IMAGE:
189 if (command instanceof RefreshType) {
190 refreshMotionImageFromHistory();
194 handleLight(command);
196 case CHANNEL_OPENDOOR1:
197 handleOpenDoor(command, "1");
199 case CHANNEL_OPENDOOR2:
200 handleOpenDoor(command, "2");
203 if (command instanceof RefreshType) {
207 case CHANNEL_DOORBELL_HISTORY_INDEX:
208 case CHANNEL_MOTION_HISTORY_INDEX:
209 if (command instanceof RefreshType) {
210 // On REFRESH, get the first history image
211 handleHistoryImage(channelUID, new DecimalType(1));
213 // Get the history image specified in the command
214 handleHistoryImage(channelUID, command);
217 case CHANNEL_DOORBELL_IMAGE_MONTAGE:
218 if (command instanceof RefreshType) {
219 updateDoorbellMontage();
222 case CHANNEL_MOTION_IMAGE_MONTAGE:
223 if (command instanceof RefreshType) {
224 updateMotionMontage();
231 public Collection<Class<? extends ThingHandlerService>> getServices() {
232 return Collections.singletonList(DoorbirdActions.class);
235 public void actionRestart() {
239 public void actionSIPHangup() {
243 public String actionGetRingTimeLimit() {
244 return getSipStatusValue(SipStatus::getRingTimeLimit);
247 public String actionGetCallTimeLimit() {
248 return getSipStatusValue(SipStatus::getCallTimeLimit);
251 public String actionGetLastErrorCode() {
252 return getSipStatusValue(SipStatus::getLastErrorCode);
255 public String actionGetLastErrorText() {
256 return getSipStatusValue(SipStatus::getLastErrorText);
259 private String getSipStatusValue(Function<SipStatus, String> function) {
261 SipStatus sipStatus = api.getSipStatus();
262 if (sipStatus != null) {
263 value = function.apply(sipStatus);
268 private void refreshDoorbellImageFromHistory() {
269 logger.debug("Handler: REFRESH doorbell image channel using most recent doorbell history image");
270 scheduler.execute(() -> {
271 DoorbirdImage dbImage = api.downloadDoorbellHistoryImage("1");
272 if (dbImage != null) {
273 RawType image = dbImage.getImage();
274 updateState(CHANNEL_DOORBELL_IMAGE, image != null ? image : UnDefType.UNDEF);
275 updateState(CHANNEL_DOORBELL_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
277 updateState(CHANNEL_DOORBELL, OnOffType.OFF);
281 private void refreshMotionImageFromHistory() {
282 logger.debug("Handler: REFRESH motion image channel using most recent motion history image");
283 scheduler.execute(() -> {
284 DoorbirdImage dbImage = api.downloadMotionHistoryImage("1");
285 if (dbImage != null) {
286 RawType image = dbImage.getImage();
287 updateState(CHANNEL_MOTION_IMAGE, image != null ? image : UnDefType.UNDEF);
288 updateState(CHANNEL_MOTION_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
290 updateState(CHANNEL_MOTION, OnOffType.OFF);
294 private void handleLight(Command command) {
295 // It's only possible to energize the light relay
296 if (command.equals(OnOffType.ON)) {
301 private void handleOpenDoor(Command command, String doorNumber) {
302 // It's only possible to energize the open door relay
303 if (command.equals(OnOffType.ON)) {
304 api.openDoorDoorbell(doorNumber);
308 private void handleGetImage() {
309 scheduler.execute(this::updateImageAndTimestamp);
312 private void handleHistoryImage(ChannelUID channelUID, Command command) {
313 if (!(command instanceof DecimalType)) {
314 logger.debug("History index must be of type DecimalType");
317 int value = ((DecimalType) command).intValue();
318 if (value < 0 || value > MAX_HISTORY_IMAGES) {
319 logger.debug("History index must be in range 1 to {}", MAX_HISTORY_IMAGES);
322 boolean isDoorbell = CHANNEL_DOORBELL_HISTORY_INDEX.equals(channelUID.getId());
323 String imageChannelId = isDoorbell ? CHANNEL_DOORBELL_HISTORY_IMAGE : CHANNEL_MOTION_HISTORY_IMAGE;
324 String timestampChannelId = isDoorbell ? CHANNEL_DOORBELL_HISTORY_TIMESTAMP : CHANNEL_MOTION_HISTORY_TIMESTAMP;
326 DoorbirdImage dbImage = isDoorbell ? api.downloadDoorbellHistoryImage(command.toString())
327 : api.downloadMotionHistoryImage(command.toString());
328 if (dbImage != null) {
329 RawType image = dbImage.getImage();
330 updateState(imageChannelId, image != null ? image : UnDefType.UNDEF);
331 updateState(timestampChannelId, getLocalDateTimeType(dbImage.getTimestamp()));
335 private void startImageRefreshJob() {
336 Integer imageRefreshRate = config.imageRefreshRate;
337 if (imageRefreshRate != null) {
338 imageRefreshJob = scheduler.scheduleWithFixedDelay(() -> {
340 updateImageAndTimestamp();
341 } catch (RuntimeException e) {
342 logger.debug("Refresh image job got unhandled exception: {}", e.getMessage(), e);
344 }, 8L, imageRefreshRate, TimeUnit.SECONDS);
345 logger.debug("Scheduled job to refresh image channel every {} seconds", imageRefreshRate);
349 private void stopImageRefreshJob() {
350 if (imageRefreshJob != null) {
351 imageRefreshJob.cancel(true);
352 imageRefreshJob = null;
353 logger.debug("Canceling image refresh job");
357 private void startUDPListenerJob() {
358 logger.debug("Listener job is scheduled to start in 5 seconds");
359 listenerJob = doorbirdScheduler.schedule(udpListener, 5, TimeUnit.SECONDS);
362 private void stopUDPListenerJob() {
363 if (listenerJob != null) {
364 listenerJob.cancel(true);
365 udpListener.shutdown();
366 logger.debug("Canceling listener job");
370 private void startDoorbellOffJob() {
371 Integer offDelay = config.doorbellOffDelay;
372 if (offDelay == null) {
375 if (doorbellOffJob != null) {
376 doorbellOffJob.cancel(true);
378 doorbellOffJob = scheduler.schedule(() -> {
379 logger.debug("Update channel 'doorbell' to OFF for thing {}", getThing().getUID());
380 triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.RELEASED);
381 }, offDelay, TimeUnit.SECONDS);
384 private void stopDoorbellOffJob() {
385 if (doorbellOffJob != null) {
386 doorbellOffJob.cancel(true);
387 doorbellOffJob = null;
388 logger.debug("Canceling doorbell off job");
392 private void startMotionOffJob() {
393 Integer offDelay = config.motionOffDelay;
394 if (offDelay == null) {
397 if (motionOffJob != null) {
398 motionOffJob.cancel(true);
400 motionOffJob = scheduler.schedule(() -> {
401 logger.debug("Update channel 'motion' to OFF for thing {}", getThing().getUID());
402 updateState(CHANNEL_MOTION, OnOffType.OFF);
403 }, offDelay, TimeUnit.SECONDS);
406 private void stopMotionOffJob() {
407 if (motionOffJob != null) {
408 motionOffJob.cancel(true);
410 logger.debug("Canceling motion off job");
414 private void updateDoorbellMontage() {
415 if (config.montageNumImages == 0) {
418 logger.debug("Scheduling DOORBELL montage update to run in {} seconds", MONTAGE_UPDATE_DELAY_SECONDS);
419 scheduler.schedule(() -> {
420 updateMontage(CHANNEL_DOORBELL_IMAGE_MONTAGE);
421 }, MONTAGE_UPDATE_DELAY_SECONDS, TimeUnit.SECONDS);
424 private void updateMotionMontage() {
425 if (config.montageNumImages == 0) {
428 logger.debug("Scheduling MOTION montage update to run in {} seconds", MONTAGE_UPDATE_DELAY_SECONDS);
429 scheduler.schedule(() -> {
430 updateMontage(CHANNEL_MOTION_IMAGE_MONTAGE);
431 }, MONTAGE_UPDATE_DELAY_SECONDS, TimeUnit.SECONDS);
434 private void updateMontage(String channelId) {
435 logger.debug("Update montage for channel '{}'", channelId);
436 ArrayList<BufferedImage> images = getImages(channelId);
437 if (!images.isEmpty()) {
438 State state = createMontage(images);
440 logger.debug("Got a montage. Updating channel '{}' with image montage", channelId);
441 updateState(channelId, state);
445 logger.debug("Updating channel '{}' with NULL image montage", channelId);
446 updateState(channelId, UnDefType.NULL);
449 // Get an array list of history images
450 private ArrayList<BufferedImage> getImages(String channelId) {
451 ArrayList<BufferedImage> images = new ArrayList<>();
452 Integer numberOfImages = config.montageNumImages;
453 if (numberOfImages != null) {
454 for (int imageNumber = 1; imageNumber <= numberOfImages; imageNumber++) {
455 logger.trace("Downloading montage image {} for channel '{}'", imageNumber, channelId);
456 DoorbirdImage historyImage = CHANNEL_DOORBELL_IMAGE_MONTAGE.equals(channelId)
457 ? api.downloadDoorbellHistoryImage(String.valueOf(imageNumber))
458 : api.downloadMotionHistoryImage(String.valueOf(imageNumber));
459 if (historyImage != null) {
460 RawType image = historyImage.getImage();
463 BufferedImage i = ImageIO.read(new ByteArrayInputStream(image.getBytes()));
465 } catch (IOException e) {
466 logger.debug("IOException creating BufferedImage from downloaded image: {}",
472 if (images.size() < numberOfImages) {
473 logger.debug("Some images could not be downloaded: wanted={}, actual={}", numberOfImages,
480 // Assemble the array of images into a single scaled image
481 private @Nullable State createMontage(ArrayList<BufferedImage> images) {
483 Integer montageScaleFactor = config.montageScaleFactor;
484 if (montageScaleFactor != null) {
485 // Assume all images are the same size, as the Doorbird image resolution cannot
486 // be changed by the user
487 int height = (int) (images.get(0).getHeight() * (montageScaleFactor / 100.0));
488 int width = (int) (images.get(0).getWidth() * (montageScaleFactor / 100.0));
489 int widthTotal = width * images.size();
490 logger.debug("Dimensions of final montage image: w={}, h={}", widthTotal, height);
492 // Create concatenated image
493 int currentWidth = 0;
494 BufferedImage concatImage = new BufferedImage(widthTotal, height, BufferedImage.TYPE_INT_RGB);
495 Graphics2D g2d = concatImage.createGraphics();
496 logger.debug("Concatenating images array into single image");
497 for (int j = 0; j < images.size(); j++) {
498 g2d.drawImage(images.get(j), currentWidth, 0, width, height, null);
499 currentWidth += width;
503 // Convert image to a state
504 logger.debug("Rendering image to byte array and converting to RawType state");
505 byte[] imageBytes = convertImageToByteArray(concatImage);
506 if (imageBytes != null) {
507 state = new RawType(imageBytes, "image/png");
513 private byte @Nullable [] convertImageToByteArray(BufferedImage image) {
515 try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
516 ImageIO.write(image, "png", out);
517 data = out.toByteArray();
518 } catch (IOException ioe) {
519 logger.debug("IOException occurred converting image to byte array", ioe);
524 private void updateImageAndTimestamp() {
525 DoorbirdImage dbImage = api.downloadCurrentImage();
526 if (dbImage != null) {
527 RawType image = dbImage.getImage();
528 updateState(CHANNEL_IMAGE, image != null ? image : UnDefType.UNDEF);
529 updateState(CHANNEL_IMAGE_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
533 private DateTimeType getLocalDateTimeType(long dateTimeSeconds) {
534 return new DateTimeType(Instant.ofEpochSecond(dateTimeSeconds).atZone(timeZoneProvider.getTimeZone()));