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.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.io.InputStream;
23 import java.time.Instant;
24 import java.util.ArrayList;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.Hashtable;
28 import java.util.concurrent.ScheduledExecutorService;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.function.Function;
33 import javax.imageio.ImageIO;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.eclipse.jetty.client.HttpClient;
38 import org.openhab.binding.doorbird.internal.action.DoorbirdActions;
39 import org.openhab.binding.doorbird.internal.api.DoorbirdAPI;
40 import org.openhab.binding.doorbird.internal.api.DoorbirdImage;
41 import org.openhab.binding.doorbird.internal.api.SipStatus;
42 import org.openhab.binding.doorbird.internal.audio.DoorbirdAudioSink;
43 import org.openhab.binding.doorbird.internal.config.DoorbellConfiguration;
44 import org.openhab.binding.doorbird.internal.listener.DoorbirdUdpListener;
45 import org.openhab.core.audio.AudioSink;
46 import org.openhab.core.common.ThreadPoolManager;
47 import org.openhab.core.i18n.TimeZoneProvider;
48 import org.openhab.core.library.types.DateTimeType;
49 import org.openhab.core.library.types.DecimalType;
50 import org.openhab.core.library.types.OnOffType;
51 import org.openhab.core.library.types.RawType;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.CommonTriggerEvents;
54 import org.openhab.core.thing.Thing;
55 import org.openhab.core.thing.ThingStatus;
56 import org.openhab.core.thing.ThingStatusDetail;
57 import org.openhab.core.thing.binding.BaseThingHandler;
58 import org.openhab.core.thing.binding.ThingHandlerService;
59 import org.openhab.core.types.Command;
60 import org.openhab.core.types.RefreshType;
61 import org.openhab.core.types.State;
62 import org.openhab.core.types.UnDefType;
63 import org.osgi.framework.BundleContext;
64 import org.osgi.framework.ServiceRegistration;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
69 * The {@link DoorbellHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Mark Hilbush - Initial contribution
75 public class DoorbellHandler extends BaseThingHandler {
76 private static final long MONTAGE_UPDATE_DELAY_SECONDS = 5L;
78 // Maximum number of doorbell and motion history images stored on Doorbird backend
79 private static final int MAX_HISTORY_IMAGES = 50;
81 private final Logger logger = LoggerFactory.getLogger(DoorbellHandler.class);
83 // Get a dedicated threadpool for the long-running listener thread
84 private final ScheduledExecutorService doorbirdScheduler = ThreadPoolManager
85 .getScheduledPool("doorbirdListener" + "-" + thing.getUID().getId());
86 private @Nullable ScheduledFuture<?> listenerJob;
87 private final DoorbirdUdpListener udpListener;
89 private @Nullable ScheduledFuture<?> imageRefreshJob;
90 private @Nullable ScheduledFuture<?> doorbellOffJob;
91 private @Nullable ScheduledFuture<?> motionOffJob;
93 private @NonNullByDefault({}) DoorbellConfiguration config;
95 private DoorbirdAPI api = new DoorbirdAPI();
97 private BundleContext bundleContext;
99 private @Nullable ServiceRegistration<AudioSink> audioSinkRegistration;
101 private final TimeZoneProvider timeZoneProvider;
102 private final HttpClient httpClient;
104 public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient,
105 BundleContext bundleContext) {
107 this.timeZoneProvider = timeZoneProvider;
108 this.httpClient = httpClient;
109 this.bundleContext = bundleContext;
110 udpListener = new DoorbirdUdpListener(this);
114 public void initialize() {
115 config = getConfigAs(DoorbellConfiguration.class);
116 String host = config.doorbirdHost;
118 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Doorbird host not provided");
121 String user = config.userId;
123 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User ID not provided");
126 String password = config.userPassword;
127 if (password == null) {
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User password not provided");
131 api.setAuthorization(host, user, password);
132 api.setHttpClient(httpClient);
133 startImageRefreshJob();
134 startUDPListenerJob();
136 updateStatus(ThingStatus.ONLINE);
140 public void dispose() {
141 stopUDPListenerJob();
142 stopImageRefreshJob();
143 stopDoorbellOffJob();
149 // Callback used by listener to get Doorbird host name
150 public @Nullable String getDoorbirdHost() {
151 return config.doorbirdHost;
154 // Callback used by listener to get Doorbird password
155 public @Nullable String getUserId() {
156 return config.userId;
159 // Callback used by listener to get Doorbird password
160 public @Nullable String getUserPassword() {
161 return config.userPassword;
164 // Callback used by listener to update doorbell channel
165 public void updateDoorbellChannel(long timestamp) {
166 logger.debug("Handler: Update DOORBELL channels for thing {}", getThing().getUID());
167 DoorbirdImage dbImage = api.downloadCurrentImage();
168 if (dbImage != null) {
169 RawType image = dbImage.getImage();
170 updateState(CHANNEL_DOORBELL_IMAGE, image != null ? image : UnDefType.UNDEF);
171 updateState(CHANNEL_DOORBELL_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
173 triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.PRESSED);
174 startDoorbellOffJob();
175 updateDoorbellMontage();
178 // Callback used by listener to update motion channel
179 public void updateMotionChannel(long timestamp) {
180 logger.debug("Handler: Update MOTION channels for thing {}", getThing().getUID());
181 DoorbirdImage dbImage = api.downloadCurrentImage();
182 if (dbImage != null) {
183 RawType image = dbImage.getImage();
184 updateState(CHANNEL_MOTION_IMAGE, image != null ? image : UnDefType.UNDEF);
185 updateState(CHANNEL_MOTION_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
187 updateState(CHANNEL_MOTION, OnOffType.ON);
189 updateMotionMontage();
193 public void handleCommand(ChannelUID channelUID, Command command) {
194 logger.debug("Got command {} for channel {} of thing {}", command, channelUID, getThing().getUID());
196 switch (channelUID.getId()) {
197 case CHANNEL_DOORBELL_IMAGE:
198 if (command instanceof RefreshType) {
199 refreshDoorbellImageFromHistory();
202 case CHANNEL_MOTION_IMAGE:
203 if (command instanceof RefreshType) {
204 refreshMotionImageFromHistory();
208 handleLight(command);
210 case CHANNEL_OPENDOOR1:
211 handleOpenDoor(command, "1");
213 case CHANNEL_OPENDOOR2:
214 handleOpenDoor(command, "2");
217 if (command instanceof RefreshType) {
221 case CHANNEL_DOORBELL_HISTORY_INDEX:
222 case CHANNEL_MOTION_HISTORY_INDEX:
223 if (command instanceof RefreshType) {
224 // On REFRESH, get the first history image
225 handleHistoryImage(channelUID, new DecimalType(1));
227 // Get the history image specified in the command
228 handleHistoryImage(channelUID, command);
231 case CHANNEL_DOORBELL_IMAGE_MONTAGE:
232 if (command instanceof RefreshType) {
233 updateDoorbellMontage();
236 case CHANNEL_MOTION_IMAGE_MONTAGE:
237 if (command instanceof RefreshType) {
238 updateMotionMontage();
245 public Collection<Class<? extends ThingHandlerService>> getServices() {
246 return Collections.singletonList(DoorbirdActions.class);
249 public void actionRestart() {
253 public void actionSIPHangup() {
257 public void sendAudio(InputStream inputStream) {
258 api.sendAudio(inputStream);
261 public String actionGetRingTimeLimit() {
262 return getSipStatusValue(SipStatus::getRingTimeLimit);
265 public String actionGetCallTimeLimit() {
266 return getSipStatusValue(SipStatus::getCallTimeLimit);
269 public String actionGetLastErrorCode() {
270 return getSipStatusValue(SipStatus::getLastErrorCode);
273 public String actionGetLastErrorText() {
274 return getSipStatusValue(SipStatus::getLastErrorText);
277 private String getSipStatusValue(Function<SipStatus, String> function) {
279 SipStatus sipStatus = api.getSipStatus();
280 if (sipStatus != null) {
281 value = function.apply(sipStatus);
286 private void refreshDoorbellImageFromHistory() {
287 logger.debug("Handler: REFRESH doorbell image channel using most recent doorbell history image");
288 scheduler.execute(() -> {
289 DoorbirdImage dbImage = api.downloadDoorbellHistoryImage("1");
290 if (dbImage != null) {
291 RawType image = dbImage.getImage();
292 updateState(CHANNEL_DOORBELL_IMAGE, image != null ? image : UnDefType.UNDEF);
293 updateState(CHANNEL_DOORBELL_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
295 updateState(CHANNEL_DOORBELL, OnOffType.OFF);
299 private void refreshMotionImageFromHistory() {
300 logger.debug("Handler: REFRESH motion image channel using most recent motion history image");
301 scheduler.execute(() -> {
302 DoorbirdImage dbImage = api.downloadMotionHistoryImage("1");
303 if (dbImage != null) {
304 RawType image = dbImage.getImage();
305 updateState(CHANNEL_MOTION_IMAGE, image != null ? image : UnDefType.UNDEF);
306 updateState(CHANNEL_MOTION_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
308 updateState(CHANNEL_MOTION, OnOffType.OFF);
312 private void handleLight(Command command) {
313 // It's only possible to energize the light relay
314 if (command.equals(OnOffType.ON)) {
319 private void handleOpenDoor(Command command, String doorNumber) {
320 // It's only possible to energize the open door relay
321 if (command.equals(OnOffType.ON)) {
322 api.openDoorDoorbell(doorNumber);
326 private void handleGetImage() {
327 scheduler.execute(this::updateImageAndTimestamp);
330 private void handleHistoryImage(ChannelUID channelUID, Command command) {
331 if (!(command instanceof DecimalType)) {
332 logger.debug("History index must be of type DecimalType");
335 int value = ((DecimalType) command).intValue();
336 if (value < 0 || value > MAX_HISTORY_IMAGES) {
337 logger.debug("History index must be in range 1 to {}", MAX_HISTORY_IMAGES);
340 boolean isDoorbell = CHANNEL_DOORBELL_HISTORY_INDEX.equals(channelUID.getId());
341 String imageChannelId = isDoorbell ? CHANNEL_DOORBELL_HISTORY_IMAGE : CHANNEL_MOTION_HISTORY_IMAGE;
342 String timestampChannelId = isDoorbell ? CHANNEL_DOORBELL_HISTORY_TIMESTAMP : CHANNEL_MOTION_HISTORY_TIMESTAMP;
344 DoorbirdImage dbImage = isDoorbell ? api.downloadDoorbellHistoryImage(command.toString())
345 : api.downloadMotionHistoryImage(command.toString());
346 if (dbImage != null) {
347 RawType image = dbImage.getImage();
348 updateState(imageChannelId, image != null ? image : UnDefType.UNDEF);
349 updateState(timestampChannelId, getLocalDateTimeType(dbImage.getTimestamp()));
353 private void startImageRefreshJob() {
354 Integer imageRefreshRate = config.imageRefreshRate;
355 if (imageRefreshRate != null) {
356 imageRefreshJob = scheduler.scheduleWithFixedDelay(() -> {
358 updateImageAndTimestamp();
359 } catch (RuntimeException e) {
360 logger.debug("Refresh image job got unhandled exception: {}", e.getMessage(), e);
362 }, 8L, imageRefreshRate, TimeUnit.SECONDS);
363 logger.debug("Scheduled job to refresh image channel every {} seconds", imageRefreshRate);
367 private void stopImageRefreshJob() {
368 if (imageRefreshJob != null) {
369 imageRefreshJob.cancel(true);
370 imageRefreshJob = null;
371 logger.debug("Canceling image refresh job");
375 private void startUDPListenerJob() {
376 logger.debug("Listener job is scheduled to start in 5 seconds");
377 listenerJob = doorbirdScheduler.schedule(udpListener, 5, TimeUnit.SECONDS);
380 private void stopUDPListenerJob() {
381 if (listenerJob != null) {
382 listenerJob.cancel(true);
383 udpListener.shutdown();
384 logger.debug("Canceling listener job");
388 private void startDoorbellOffJob() {
389 Integer offDelay = config.doorbellOffDelay;
390 if (offDelay == null) {
393 if (doorbellOffJob != null) {
394 doorbellOffJob.cancel(true);
396 doorbellOffJob = scheduler.schedule(() -> {
397 logger.debug("Update channel 'doorbell' to OFF for thing {}", getThing().getUID());
398 triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.RELEASED);
399 }, offDelay, TimeUnit.SECONDS);
402 private void stopDoorbellOffJob() {
403 if (doorbellOffJob != null) {
404 doorbellOffJob.cancel(true);
405 doorbellOffJob = null;
406 logger.debug("Canceling doorbell off job");
410 private void startMotionOffJob() {
411 Integer offDelay = config.motionOffDelay;
412 if (offDelay == null) {
415 if (motionOffJob != null) {
416 motionOffJob.cancel(true);
418 motionOffJob = scheduler.schedule(() -> {
419 logger.debug("Update channel 'motion' to OFF for thing {}", getThing().getUID());
420 updateState(CHANNEL_MOTION, OnOffType.OFF);
421 }, offDelay, TimeUnit.SECONDS);
424 private void stopMotionOffJob() {
425 if (motionOffJob != null) {
426 motionOffJob.cancel(true);
428 logger.debug("Canceling motion off job");
432 private void startAudioSink() {
433 final DoorbellHandler thisHandler = this;
434 // Register an audio sink in openhab
435 logger.trace("Registering an audio sink for this {}", thing.getUID());
436 audioSinkRegistration = bundleContext.registerService(AudioSink.class, new DoorbirdAudioSink(thisHandler),
440 private void stopAudioSink() {
441 // Unregister the doorbird audio sink
442 ServiceRegistration<AudioSink> audioSinkRegistrationLocal = audioSinkRegistration;
443 if (audioSinkRegistrationLocal != null) {
444 logger.trace("Unregistering the audio sync service for the doorbird thing {}", getThing().getUID());
445 audioSinkRegistrationLocal.unregister();
449 private void updateDoorbellMontage() {
450 if (config.montageNumImages == 0) {
453 logger.debug("Scheduling DOORBELL montage update to run in {} seconds", MONTAGE_UPDATE_DELAY_SECONDS);
454 scheduler.schedule(() -> {
455 updateMontage(CHANNEL_DOORBELL_IMAGE_MONTAGE);
456 }, MONTAGE_UPDATE_DELAY_SECONDS, TimeUnit.SECONDS);
459 private void updateMotionMontage() {
460 if (config.montageNumImages == 0) {
463 logger.debug("Scheduling MOTION montage update to run in {} seconds", MONTAGE_UPDATE_DELAY_SECONDS);
464 scheduler.schedule(() -> {
465 updateMontage(CHANNEL_MOTION_IMAGE_MONTAGE);
466 }, MONTAGE_UPDATE_DELAY_SECONDS, TimeUnit.SECONDS);
469 private void updateMontage(String channelId) {
470 logger.debug("Update montage for channel '{}'", channelId);
471 ArrayList<BufferedImage> images = getImages(channelId);
472 if (!images.isEmpty()) {
473 State state = createMontage(images);
475 logger.debug("Got a montage. Updating channel '{}' with image montage", channelId);
476 updateState(channelId, state);
480 logger.debug("Updating channel '{}' with NULL image montage", channelId);
481 updateState(channelId, UnDefType.NULL);
484 // Get an array list of history images
485 private ArrayList<BufferedImage> getImages(String channelId) {
486 ArrayList<BufferedImage> images = new ArrayList<>();
487 Integer numberOfImages = config.montageNumImages;
488 if (numberOfImages != null) {
489 for (int imageNumber = 1; imageNumber <= numberOfImages; imageNumber++) {
490 logger.trace("Downloading montage image {} for channel '{}'", imageNumber, channelId);
491 DoorbirdImage historyImage = CHANNEL_DOORBELL_IMAGE_MONTAGE.equals(channelId)
492 ? api.downloadDoorbellHistoryImage(String.valueOf(imageNumber))
493 : api.downloadMotionHistoryImage(String.valueOf(imageNumber));
494 if (historyImage != null) {
495 RawType image = historyImage.getImage();
498 BufferedImage i = ImageIO.read(new ByteArrayInputStream(image.getBytes()));
500 } catch (IOException e) {
501 logger.debug("IOException creating BufferedImage from downloaded image: {}",
507 if (images.size() < numberOfImages) {
508 logger.debug("Some images could not be downloaded: wanted={}, actual={}", numberOfImages,
515 // Assemble the array of images into a single scaled image
516 private @Nullable State createMontage(ArrayList<BufferedImage> images) {
518 Integer montageScaleFactor = config.montageScaleFactor;
519 if (montageScaleFactor != null) {
520 // Assume all images are the same size, as the Doorbird image resolution cannot
521 // be changed by the user
522 int height = (int) (images.get(0).getHeight() * (montageScaleFactor / 100.0));
523 int width = (int) (images.get(0).getWidth() * (montageScaleFactor / 100.0));
524 int widthTotal = width * images.size();
525 logger.debug("Dimensions of final montage image: w={}, h={}", widthTotal, height);
527 // Create concatenated image
528 int currentWidth = 0;
529 BufferedImage concatImage = new BufferedImage(widthTotal, height, BufferedImage.TYPE_INT_RGB);
530 Graphics2D g2d = concatImage.createGraphics();
531 logger.debug("Concatenating images array into single image");
532 for (int j = 0; j < images.size(); j++) {
533 g2d.drawImage(images.get(j), currentWidth, 0, width, height, null);
534 currentWidth += width;
538 // Convert image to a state
539 logger.debug("Rendering image to byte array and converting to RawType state");
540 byte[] imageBytes = convertImageToByteArray(concatImage);
541 if (imageBytes != null) {
542 state = new RawType(imageBytes, "image/png");
548 private byte @Nullable [] convertImageToByteArray(BufferedImage image) {
550 try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
551 ImageIO.write(image, "png", out);
552 data = out.toByteArray();
553 } catch (IOException ioe) {
554 logger.debug("IOException occurred converting image to byte array", ioe);
559 private void updateImageAndTimestamp() {
560 DoorbirdImage dbImage = api.downloadCurrentImage();
561 if (dbImage != null) {
562 RawType image = dbImage.getImage();
563 updateState(CHANNEL_IMAGE, image != null ? image : UnDefType.UNDEF);
564 updateState(CHANNEL_IMAGE_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
568 private DateTimeType getLocalDateTimeType(long dateTimeSeconds) {
569 return new DateTimeType(Instant.ofEpochSecond(dateTimeSeconds).atZone(timeZoneProvider.getTimeZone()));