]> git.basschouten.com Git - openhab-addons.git/blob
a219c90f0fb8d91aedf443b6f7a77cfa7ad6990a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.doorbird.internal.handler;
14
15 import static org.openhab.binding.doorbird.internal.DoorbirdBindingConstants.*;
16
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;
30
31 import javax.imageio.ImageIO;
32
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;
61
62 /**
63  * The {@link DoorbellHandler} is responsible for handling commands, which are
64  * sent to one of the channels.
65  *
66  * @author Mark Hilbush - Initial contribution
67  */
68 @NonNullByDefault
69 public class DoorbellHandler extends BaseThingHandler {
70     private static final long MONTAGE_UPDATE_DELAY_SECONDS = 5L;
71
72     // Maximum number of doorbell and motion history images stored on Doorbird backend
73     private static final int MAX_HISTORY_IMAGES = 50;
74
75     private final Logger logger = LoggerFactory.getLogger(DoorbellHandler.class);
76
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;
82
83     private @Nullable ScheduledFuture<?> imageRefreshJob;
84     private @Nullable ScheduledFuture<?> doorbellOffJob;
85     private @Nullable ScheduledFuture<?> motionOffJob;
86
87     private @NonNullByDefault({}) DoorbellConfiguration config;
88
89     private DoorbirdAPI api = new DoorbirdAPI();
90
91     private final TimeZoneProvider timeZoneProvider;
92     private final HttpClient httpClient;
93
94     public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient) {
95         super(thing);
96         this.timeZoneProvider = timeZoneProvider;
97         this.httpClient = httpClient;
98         udpListener = new DoorbirdUdpListener(this);
99     }
100
101     @Override
102     public void initialize() {
103         config = getConfigAs(DoorbellConfiguration.class);
104         String host = config.doorbirdHost;
105         if (host == null) {
106             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Doorbird host not provided");
107             return;
108         }
109         String user = config.userId;
110         if (user == null) {
111             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User ID not provided");
112             return;
113         }
114         String password = config.userPassword;
115         if (password == null) {
116             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User password not provided");
117             return;
118         }
119         api.setAuthorization(host, user, password);
120         api.setHttpClient(httpClient);
121         startImageRefreshJob();
122         startUDPListenerJob();
123         updateStatus(ThingStatus.ONLINE);
124     }
125
126     @Override
127     public void dispose() {
128         stopUDPListenerJob();
129         stopImageRefreshJob();
130         stopDoorbellOffJob();
131         stopMotionOffJob();
132         super.dispose();
133     }
134
135     // Callback used by listener to get Doorbird host name
136     public @Nullable String getDoorbirdHost() {
137         return config.doorbirdHost;
138     }
139
140     // Callback used by listener to get Doorbird password
141     public @Nullable String getUserId() {
142         return config.userId;
143     }
144
145     // Callback used by listener to get Doorbird password
146     public @Nullable String getUserPassword() {
147         return config.userPassword;
148     }
149
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()));
158         }
159         triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.PRESSED);
160         startDoorbellOffJob();
161         updateDoorbellMontage();
162     }
163
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()));
172         }
173         updateState(CHANNEL_MOTION, OnOffType.ON);
174         startMotionOffJob();
175         updateMotionMontage();
176     }
177
178     @Override
179     public void handleCommand(ChannelUID channelUID, Command command) {
180         logger.debug("Got command {} for channel {} of thing {}", command, channelUID, getThing().getUID());
181
182         switch (channelUID.getId()) {
183             case CHANNEL_DOORBELL_IMAGE:
184                 if (command instanceof RefreshType) {
185                     refreshDoorbellImageFromHistory();
186                 }
187                 break;
188             case CHANNEL_MOTION_IMAGE:
189                 if (command instanceof RefreshType) {
190                     refreshMotionImageFromHistory();
191                 }
192                 break;
193             case CHANNEL_LIGHT:
194                 handleLight(command);
195                 break;
196             case CHANNEL_OPENDOOR1:
197                 handleOpenDoor(command, "1");
198                 break;
199             case CHANNEL_OPENDOOR2:
200                 handleOpenDoor(command, "2");
201                 break;
202             case CHANNEL_IMAGE:
203                 if (command instanceof RefreshType) {
204                     handleGetImage();
205                 }
206                 break;
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));
212                 } else {
213                     // Get the history image specified in the command
214                     handleHistoryImage(channelUID, command);
215                 }
216                 break;
217             case CHANNEL_DOORBELL_IMAGE_MONTAGE:
218                 if (command instanceof RefreshType) {
219                     updateDoorbellMontage();
220                 }
221                 break;
222             case CHANNEL_MOTION_IMAGE_MONTAGE:
223                 if (command instanceof RefreshType) {
224                     updateMotionMontage();
225                 }
226                 break;
227         }
228     }
229
230     @Override
231     public Collection<Class<? extends ThingHandlerService>> getServices() {
232         return Collections.singletonList(DoorbirdActions.class);
233     }
234
235     public void actionRestart() {
236         api.restart();
237     }
238
239     public void actionSIPHangup() {
240         api.sipHangup();
241     }
242
243     public String actionGetRingTimeLimit() {
244         return getSipStatusValue(SipStatus::getRingTimeLimit);
245     }
246
247     public String actionGetCallTimeLimit() {
248         return getSipStatusValue(SipStatus::getCallTimeLimit);
249     }
250
251     public String actionGetLastErrorCode() {
252         return getSipStatusValue(SipStatus::getLastErrorCode);
253     }
254
255     public String actionGetLastErrorText() {
256         return getSipStatusValue(SipStatus::getLastErrorText);
257     }
258
259     private String getSipStatusValue(Function<SipStatus, String> function) {
260         String value = "";
261         SipStatus sipStatus = api.getSipStatus();
262         if (sipStatus != null) {
263             value = function.apply(sipStatus);
264         }
265         return value;
266     }
267
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()));
276             }
277             updateState(CHANNEL_DOORBELL, OnOffType.OFF);
278         });
279     }
280
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()));
289             }
290             updateState(CHANNEL_MOTION, OnOffType.OFF);
291         });
292     }
293
294     private void handleLight(Command command) {
295         // It's only possible to energize the light relay
296         if (command.equals(OnOffType.ON)) {
297             api.lightOn();
298         }
299     }
300
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);
305         }
306     }
307
308     private void handleGetImage() {
309         scheduler.execute(this::updateImageAndTimestamp);
310     }
311
312     private void handleHistoryImage(ChannelUID channelUID, Command command) {
313         if (!(command instanceof DecimalType)) {
314             logger.debug("History index must be of type DecimalType");
315             return;
316         }
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);
320             return;
321         }
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;
325
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()));
332         }
333     }
334
335     private void startImageRefreshJob() {
336         Integer imageRefreshRate = config.imageRefreshRate;
337         if (imageRefreshRate != null) {
338             imageRefreshJob = scheduler.scheduleWithFixedDelay(() -> {
339                 try {
340                     updateImageAndTimestamp();
341                 } catch (RuntimeException e) {
342                     logger.debug("Refresh image job got unhandled exception: {}", e.getMessage(), e);
343                 }
344             }, 8L, imageRefreshRate, TimeUnit.SECONDS);
345             logger.debug("Scheduled job to refresh image channel every {} seconds", imageRefreshRate);
346         }
347     }
348
349     private void stopImageRefreshJob() {
350         if (imageRefreshJob != null) {
351             imageRefreshJob.cancel(true);
352             imageRefreshJob = null;
353             logger.debug("Canceling image refresh job");
354         }
355     }
356
357     private void startUDPListenerJob() {
358         logger.debug("Listener job is scheduled to start in 5 seconds");
359         listenerJob = doorbirdScheduler.schedule(udpListener, 5, TimeUnit.SECONDS);
360     }
361
362     private void stopUDPListenerJob() {
363         if (listenerJob != null) {
364             listenerJob.cancel(true);
365             udpListener.shutdown();
366             logger.debug("Canceling listener job");
367         }
368     }
369
370     private void startDoorbellOffJob() {
371         Integer offDelay = config.doorbellOffDelay;
372         if (offDelay == null) {
373             return;
374         }
375         if (doorbellOffJob != null) {
376             doorbellOffJob.cancel(true);
377         }
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);
382     }
383
384     private void stopDoorbellOffJob() {
385         if (doorbellOffJob != null) {
386             doorbellOffJob.cancel(true);
387             doorbellOffJob = null;
388             logger.debug("Canceling doorbell off job");
389         }
390     }
391
392     private void startMotionOffJob() {
393         Integer offDelay = config.motionOffDelay;
394         if (offDelay == null) {
395             return;
396         }
397         if (motionOffJob != null) {
398             motionOffJob.cancel(true);
399         }
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);
404     }
405
406     private void stopMotionOffJob() {
407         if (motionOffJob != null) {
408             motionOffJob.cancel(true);
409             motionOffJob = null;
410             logger.debug("Canceling motion off job");
411         }
412     }
413
414     private void updateDoorbellMontage() {
415         if (config.montageNumImages == 0) {
416             return;
417         }
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);
422     }
423
424     private void updateMotionMontage() {
425         if (config.montageNumImages == 0) {
426             return;
427         }
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);
432     }
433
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);
439             if (state != null) {
440                 logger.debug("Got a montage. Updating channel '{}' with image montage", channelId);
441                 updateState(channelId, state);
442                 return;
443             }
444         }
445         logger.debug("Updating channel '{}' with NULL image montage", channelId);
446         updateState(channelId, UnDefType.NULL);
447     }
448
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();
461                     if (image != null) {
462                         try {
463                             BufferedImage i = ImageIO.read(new ByteArrayInputStream(image.getBytes()));
464                             images.add(i);
465                         } catch (IOException e) {
466                             logger.debug("IOException creating BufferedImage from downloaded image: {}",
467                                     e.getMessage());
468                         }
469                     }
470                 }
471             }
472             if (images.size() < numberOfImages) {
473                 logger.debug("Some images could not be downloaded: wanted={}, actual={}", numberOfImages,
474                         images.size());
475             }
476         }
477         return images;
478     }
479
480     // Assemble the array of images into a single scaled image
481     private @Nullable State createMontage(ArrayList<BufferedImage> images) {
482         State state = null;
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);
491
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;
500             }
501             g2d.dispose();
502
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");
508             }
509         }
510         return state;
511     }
512
513     private byte @Nullable [] convertImageToByteArray(BufferedImage image) {
514         byte[] data = null;
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);
520         }
521         return data;
522     }
523
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()));
530         }
531     }
532
533     private DateTimeType getLocalDateTimeType(long dateTimeSeconds) {
534         return new DateTimeType(Instant.ofEpochSecond(dateTimeSeconds).atZone(timeZoneProvider.getTimeZone()));
535     }
536 }