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 ScheduledFuture<?> imageRefreshJob = this.imageRefreshJob;
369 if (imageRefreshJob != null) {
370 imageRefreshJob.cancel(true);
371 this.imageRefreshJob = null;
372 logger.debug("Canceling image refresh job");
376 private void startUDPListenerJob() {
377 logger.debug("Listener job is scheduled to start in 5 seconds");
378 listenerJob = doorbirdScheduler.schedule(udpListener, 5, TimeUnit.SECONDS);
381 private void stopUDPListenerJob() {
382 ScheduledFuture<?> listenerJob = this.listenerJob;
383 if (listenerJob != null) {
384 listenerJob.cancel(true);
385 udpListener.shutdown();
386 this.listenerJob = null;
387 logger.debug("Canceling listener job");
391 private void startDoorbellOffJob() {
392 Integer offDelay = config.doorbellOffDelay;
393 if (offDelay == null) {
396 ScheduledFuture<?> doorbellOffJob = this.doorbellOffJob;
397 if (doorbellOffJob != null) {
398 doorbellOffJob.cancel(true);
400 this.doorbellOffJob = scheduler.schedule(() -> {
401 logger.debug("Update channel 'doorbell' to OFF for thing {}", getThing().getUID());
402 triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.RELEASED);
403 }, offDelay, TimeUnit.SECONDS);
406 private void stopDoorbellOffJob() {
407 ScheduledFuture<?> doorbellOffJob = this.doorbellOffJob;
408 if (doorbellOffJob != null) {
409 doorbellOffJob.cancel(true);
410 this.doorbellOffJob = null;
411 logger.debug("Canceling doorbell off job");
415 private void startMotionOffJob() {
416 Integer offDelay = config.motionOffDelay;
417 if (offDelay == null) {
420 ScheduledFuture<?> motionOffJob = this.motionOffJob;
421 if (motionOffJob != null) {
422 motionOffJob.cancel(true);
424 this.motionOffJob = scheduler.schedule(() -> {
425 logger.debug("Update channel 'motion' to OFF for thing {}", getThing().getUID());
426 updateState(CHANNEL_MOTION, OnOffType.OFF);
427 }, offDelay, TimeUnit.SECONDS);
430 private void stopMotionOffJob() {
431 ScheduledFuture<?> motionOffJob = this.motionOffJob;
432 if (motionOffJob != null) {
433 motionOffJob.cancel(true);
434 this.motionOffJob = null;
435 logger.debug("Canceling motion off job");
439 private void startAudioSink() {
440 final DoorbellHandler thisHandler = this;
441 // Register an audio sink in openhab
442 logger.trace("Registering an audio sink for this {}", thing.getUID());
443 audioSinkRegistration = bundleContext.registerService(AudioSink.class, new DoorbirdAudioSink(thisHandler),
447 private void stopAudioSink() {
448 // Unregister the doorbird audio sink
449 ServiceRegistration<AudioSink> audioSinkRegistrationLocal = audioSinkRegistration;
450 if (audioSinkRegistrationLocal != null) {
451 logger.trace("Unregistering the audio sync service for the doorbird thing {}", getThing().getUID());
452 audioSinkRegistrationLocal.unregister();
456 private void updateDoorbellMontage() {
457 if (config.montageNumImages == 0) {
460 logger.debug("Scheduling DOORBELL montage update to run in {} seconds", MONTAGE_UPDATE_DELAY_SECONDS);
461 scheduler.schedule(() -> {
462 updateMontage(CHANNEL_DOORBELL_IMAGE_MONTAGE);
463 }, MONTAGE_UPDATE_DELAY_SECONDS, TimeUnit.SECONDS);
466 private void updateMotionMontage() {
467 if (config.montageNumImages == 0) {
470 logger.debug("Scheduling MOTION montage update to run in {} seconds", MONTAGE_UPDATE_DELAY_SECONDS);
471 scheduler.schedule(() -> {
472 updateMontage(CHANNEL_MOTION_IMAGE_MONTAGE);
473 }, MONTAGE_UPDATE_DELAY_SECONDS, TimeUnit.SECONDS);
476 private void updateMontage(String channelId) {
477 logger.debug("Update montage for channel '{}'", channelId);
478 ArrayList<BufferedImage> images = getImages(channelId);
479 if (!images.isEmpty()) {
480 State state = createMontage(images);
482 logger.debug("Got a montage. Updating channel '{}' with image montage", channelId);
483 updateState(channelId, state);
487 logger.debug("Updating channel '{}' with NULL image montage", channelId);
488 updateState(channelId, UnDefType.NULL);
491 // Get an array list of history images
492 private ArrayList<BufferedImage> getImages(String channelId) {
493 ArrayList<BufferedImage> images = new ArrayList<>();
494 Integer numberOfImages = config.montageNumImages;
495 if (numberOfImages != null) {
496 for (int imageNumber = 1; imageNumber <= numberOfImages; imageNumber++) {
497 logger.trace("Downloading montage image {} for channel '{}'", imageNumber, channelId);
498 DoorbirdImage historyImage = CHANNEL_DOORBELL_IMAGE_MONTAGE.equals(channelId)
499 ? api.downloadDoorbellHistoryImage(String.valueOf(imageNumber))
500 : api.downloadMotionHistoryImage(String.valueOf(imageNumber));
501 if (historyImage != null) {
502 RawType image = historyImage.getImage();
505 BufferedImage i = ImageIO.read(new ByteArrayInputStream(image.getBytes()));
507 } catch (IOException e) {
508 logger.debug("IOException creating BufferedImage from downloaded image: {}",
514 if (images.size() < numberOfImages) {
515 logger.debug("Some images could not be downloaded: wanted={}, actual={}", numberOfImages,
522 // Assemble the array of images into a single scaled image
523 private @Nullable State createMontage(ArrayList<BufferedImage> images) {
525 Integer montageScaleFactor = config.montageScaleFactor;
526 if (montageScaleFactor != null) {
527 // Assume all images are the same size, as the Doorbird image resolution cannot
528 // be changed by the user
529 int height = (int) (images.get(0).getHeight() * (montageScaleFactor / 100.0));
530 int width = (int) (images.get(0).getWidth() * (montageScaleFactor / 100.0));
531 int widthTotal = width * images.size();
532 logger.debug("Dimensions of final montage image: w={}, h={}", widthTotal, height);
534 // Create concatenated image
535 int currentWidth = 0;
536 BufferedImage concatImage = new BufferedImage(widthTotal, height, BufferedImage.TYPE_INT_RGB);
537 Graphics2D g2d = concatImage.createGraphics();
538 logger.debug("Concatenating images array into single image");
539 for (int j = 0; j < images.size(); j++) {
540 g2d.drawImage(images.get(j), currentWidth, 0, width, height, null);
541 currentWidth += width;
545 // Convert image to a state
546 logger.debug("Rendering image to byte array and converting to RawType state");
547 byte[] imageBytes = convertImageToByteArray(concatImage);
548 if (imageBytes != null) {
549 state = new RawType(imageBytes, "image/png");
555 private byte @Nullable [] convertImageToByteArray(BufferedImage image) {
557 try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
558 ImageIO.write(image, "png", out);
559 data = out.toByteArray();
560 } catch (IOException ioe) {
561 logger.debug("IOException occurred converting image to byte array", ioe);
566 private void updateImageAndTimestamp() {
567 DoorbirdImage dbImage = api.downloadCurrentImage();
568 if (dbImage != null) {
569 RawType image = dbImage.getImage();
570 updateState(CHANNEL_IMAGE, image != null ? image : UnDefType.UNDEF);
571 updateState(CHANNEL_IMAGE_TIMESTAMP, getLocalDateTimeType(dbImage.getTimestamp()));
575 private DateTimeType getLocalDateTimeType(long dateTimeSeconds) {
576 return new DateTimeType(Instant.ofEpochSecond(dateTimeSeconds).atZone(timeZoneProvider.getTimeZone()));