]> git.basschouten.com Git - openhab-addons.git/blob
e625fa8a5e4009edba403cceddc2adc3e844a415
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.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;
32
33 import javax.imageio.ImageIO;
34
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;
67
68 /**
69  * The {@link DoorbellHandler} is responsible for handling commands, which are
70  * sent to one of the channels.
71  *
72  * @author Mark Hilbush - Initial contribution
73  */
74 @NonNullByDefault
75 public class DoorbellHandler extends BaseThingHandler {
76     private static final long MONTAGE_UPDATE_DELAY_SECONDS = 5L;
77
78     // Maximum number of doorbell and motion history images stored on Doorbird backend
79     private static final int MAX_HISTORY_IMAGES = 50;
80
81     private final Logger logger = LoggerFactory.getLogger(DoorbellHandler.class);
82
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;
88
89     private @Nullable ScheduledFuture<?> imageRefreshJob;
90     private @Nullable ScheduledFuture<?> doorbellOffJob;
91     private @Nullable ScheduledFuture<?> motionOffJob;
92
93     private @NonNullByDefault({}) DoorbellConfiguration config;
94
95     private DoorbirdAPI api = new DoorbirdAPI();
96
97     private BundleContext bundleContext;
98
99     private @Nullable ServiceRegistration<AudioSink> audioSinkRegistration;
100
101     private final TimeZoneProvider timeZoneProvider;
102     private final HttpClient httpClient;
103
104     public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient,
105             BundleContext bundleContext) {
106         super(thing);
107         this.timeZoneProvider = timeZoneProvider;
108         this.httpClient = httpClient;
109         this.bundleContext = bundleContext;
110         udpListener = new DoorbirdUdpListener(this);
111     }
112
113     @Override
114     public void initialize() {
115         config = getConfigAs(DoorbellConfiguration.class);
116         String host = config.doorbirdHost;
117         if (host == null) {
118             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Doorbird host not provided");
119             return;
120         }
121         String user = config.userId;
122         if (user == null) {
123             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User ID not provided");
124             return;
125         }
126         String password = config.userPassword;
127         if (password == null) {
128             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User password not provided");
129             return;
130         }
131         api.setAuthorization(host, user, password);
132         api.setHttpClient(httpClient);
133         startImageRefreshJob();
134         startUDPListenerJob();
135         startAudioSink();
136         updateStatus(ThingStatus.ONLINE);
137     }
138
139     @Override
140     public void dispose() {
141         stopUDPListenerJob();
142         stopImageRefreshJob();
143         stopDoorbellOffJob();
144         stopMotionOffJob();
145         stopAudioSink();
146         super.dispose();
147     }
148
149     // Callback used by listener to get Doorbird host name
150     public @Nullable String getDoorbirdHost() {
151         return config.doorbirdHost;
152     }
153
154     // Callback used by listener to get Doorbird password
155     public @Nullable String getUserId() {
156         return config.userId;
157     }
158
159     // Callback used by listener to get Doorbird password
160     public @Nullable String getUserPassword() {
161         return config.userPassword;
162     }
163
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()));
172         }
173         triggerChannel(CHANNEL_DOORBELL, CommonTriggerEvents.PRESSED);
174         startDoorbellOffJob();
175         updateDoorbellMontage();
176     }
177
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()));
186         }
187         updateState(CHANNEL_MOTION, OnOffType.ON);
188         startMotionOffJob();
189         updateMotionMontage();
190     }
191
192     @Override
193     public void handleCommand(ChannelUID channelUID, Command command) {
194         logger.debug("Got command {} for channel {} of thing {}", command, channelUID, getThing().getUID());
195
196         switch (channelUID.getId()) {
197             case CHANNEL_DOORBELL_IMAGE:
198                 if (command instanceof RefreshType) {
199                     refreshDoorbellImageFromHistory();
200                 }
201                 break;
202             case CHANNEL_MOTION_IMAGE:
203                 if (command instanceof RefreshType) {
204                     refreshMotionImageFromHistory();
205                 }
206                 break;
207             case CHANNEL_LIGHT:
208                 handleLight(command);
209                 break;
210             case CHANNEL_OPENDOOR1:
211                 handleOpenDoor(command, "1");
212                 break;
213             case CHANNEL_OPENDOOR2:
214                 handleOpenDoor(command, "2");
215                 break;
216             case CHANNEL_IMAGE:
217                 if (command instanceof RefreshType) {
218                     handleGetImage();
219                 }
220                 break;
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));
226                 } else {
227                     // Get the history image specified in the command
228                     handleHistoryImage(channelUID, command);
229                 }
230                 break;
231             case CHANNEL_DOORBELL_IMAGE_MONTAGE:
232                 if (command instanceof RefreshType) {
233                     updateDoorbellMontage();
234                 }
235                 break;
236             case CHANNEL_MOTION_IMAGE_MONTAGE:
237                 if (command instanceof RefreshType) {
238                     updateMotionMontage();
239                 }
240                 break;
241         }
242     }
243
244     @Override
245     public Collection<Class<? extends ThingHandlerService>> getServices() {
246         return Collections.singletonList(DoorbirdActions.class);
247     }
248
249     public void actionRestart() {
250         api.restart();
251     }
252
253     public void actionSIPHangup() {
254         api.sipHangup();
255     }
256
257     public void sendAudio(InputStream inputStream) {
258         api.sendAudio(inputStream);
259     }
260
261     public String actionGetRingTimeLimit() {
262         return getSipStatusValue(SipStatus::getRingTimeLimit);
263     }
264
265     public String actionGetCallTimeLimit() {
266         return getSipStatusValue(SipStatus::getCallTimeLimit);
267     }
268
269     public String actionGetLastErrorCode() {
270         return getSipStatusValue(SipStatus::getLastErrorCode);
271     }
272
273     public String actionGetLastErrorText() {
274         return getSipStatusValue(SipStatus::getLastErrorText);
275     }
276
277     private String getSipStatusValue(Function<SipStatus, String> function) {
278         String value = "";
279         SipStatus sipStatus = api.getSipStatus();
280         if (sipStatus != null) {
281             value = function.apply(sipStatus);
282         }
283         return value;
284     }
285
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()));
294             }
295             updateState(CHANNEL_DOORBELL, OnOffType.OFF);
296         });
297     }
298
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()));
307             }
308             updateState(CHANNEL_MOTION, OnOffType.OFF);
309         });
310     }
311
312     private void handleLight(Command command) {
313         // It's only possible to energize the light relay
314         if (command.equals(OnOffType.ON)) {
315             api.lightOn();
316         }
317     }
318
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);
323         }
324     }
325
326     private void handleGetImage() {
327         scheduler.execute(this::updateImageAndTimestamp);
328     }
329
330     private void handleHistoryImage(ChannelUID channelUID, Command command) {
331         if (!(command instanceof DecimalType)) {
332             logger.debug("History index must be of type DecimalType");
333             return;
334         }
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);
338             return;
339         }
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;
343
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()));
350         }
351     }
352
353     private void startImageRefreshJob() {
354         Integer imageRefreshRate = config.imageRefreshRate;
355         if (imageRefreshRate != null) {
356             imageRefreshJob = scheduler.scheduleWithFixedDelay(() -> {
357                 try {
358                     updateImageAndTimestamp();
359                 } catch (RuntimeException e) {
360                     logger.debug("Refresh image job got unhandled exception: {}", e.getMessage(), e);
361                 }
362             }, 8L, imageRefreshRate, TimeUnit.SECONDS);
363             logger.debug("Scheduled job to refresh image channel every {} seconds", imageRefreshRate);
364         }
365     }
366
367     private void stopImageRefreshJob() {
368         if (imageRefreshJob != null) {
369             imageRefreshJob.cancel(true);
370             imageRefreshJob = null;
371             logger.debug("Canceling image refresh job");
372         }
373     }
374
375     private void startUDPListenerJob() {
376         logger.debug("Listener job is scheduled to start in 5 seconds");
377         listenerJob = doorbirdScheduler.schedule(udpListener, 5, TimeUnit.SECONDS);
378     }
379
380     private void stopUDPListenerJob() {
381         if (listenerJob != null) {
382             listenerJob.cancel(true);
383             udpListener.shutdown();
384             logger.debug("Canceling listener job");
385         }
386     }
387
388     private void startDoorbellOffJob() {
389         Integer offDelay = config.doorbellOffDelay;
390         if (offDelay == null) {
391             return;
392         }
393         if (doorbellOffJob != null) {
394             doorbellOffJob.cancel(true);
395         }
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);
400     }
401
402     private void stopDoorbellOffJob() {
403         if (doorbellOffJob != null) {
404             doorbellOffJob.cancel(true);
405             doorbellOffJob = null;
406             logger.debug("Canceling doorbell off job");
407         }
408     }
409
410     private void startMotionOffJob() {
411         Integer offDelay = config.motionOffDelay;
412         if (offDelay == null) {
413             return;
414         }
415         if (motionOffJob != null) {
416             motionOffJob.cancel(true);
417         }
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);
422     }
423
424     private void stopMotionOffJob() {
425         if (motionOffJob != null) {
426             motionOffJob.cancel(true);
427             motionOffJob = null;
428             logger.debug("Canceling motion off job");
429         }
430     }
431
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),
437                 new Hashtable<>());
438     }
439
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();
446         }
447     }
448
449     private void updateDoorbellMontage() {
450         if (config.montageNumImages == 0) {
451             return;
452         }
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);
457     }
458
459     private void updateMotionMontage() {
460         if (config.montageNumImages == 0) {
461             return;
462         }
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);
467     }
468
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);
474             if (state != null) {
475                 logger.debug("Got a montage. Updating channel '{}' with image montage", channelId);
476                 updateState(channelId, state);
477                 return;
478             }
479         }
480         logger.debug("Updating channel '{}' with NULL image montage", channelId);
481         updateState(channelId, UnDefType.NULL);
482     }
483
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();
496                     if (image != null) {
497                         try {
498                             BufferedImage i = ImageIO.read(new ByteArrayInputStream(image.getBytes()));
499                             images.add(i);
500                         } catch (IOException e) {
501                             logger.debug("IOException creating BufferedImage from downloaded image: {}",
502                                     e.getMessage());
503                         }
504                     }
505                 }
506             }
507             if (images.size() < numberOfImages) {
508                 logger.debug("Some images could not be downloaded: wanted={}, actual={}", numberOfImages,
509                         images.size());
510             }
511         }
512         return images;
513     }
514
515     // Assemble the array of images into a single scaled image
516     private @Nullable State createMontage(ArrayList<BufferedImage> images) {
517         State state = null;
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);
526
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;
535             }
536             g2d.dispose();
537
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");
543             }
544         }
545         return state;
546     }
547
548     private byte @Nullable [] convertImageToByteArray(BufferedImage image) {
549         byte[] data = null;
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);
555         }
556         return data;
557     }
558
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()));
565         }
566     }
567
568     private DateTimeType getLocalDateTimeType(long dateTimeSeconds) {
569         return new DateTimeType(Instant.ofEpochSecond(dateTimeSeconds).atZone(timeZoneProvider.getTimeZone()));
570     }
571 }