protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_D101.equals(thingTypeUID) || THING_TYPE_D210X.equals(thingTypeUID)) {
- return new DoorbellHandler(thing, timeZoneProvider, httpClient);
+ return new DoorbellHandler(thing, timeZoneProvider, httpClient, bundleContext);
} else if (THING_TYPE_A1081.equals(thingTypeUID)) {
return new ControllerHandler(thing);
}
package org.openhab.binding.doorbird.internal.api;
import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.ZonedDateTime;
+import java.util.Arrays;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.api.Response;
+import org.eclipse.jetty.client.api.Result;
+import org.eclipse.jetty.client.util.DeferredContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
private @Nullable Authorization authorization;
private @Nullable HttpClient httpClient;
+ // define a completed listener when sending audio asynchronously :
+ private Response.CompleteListener complete = new Response.CompleteListener() {
+ @Override
+ public void onComplete(@Nullable Result result) {
+ if (result != null) {
+ logger.debug("Doorbird audio sent. Response status {} {} ", result.getResponse().getStatus(),
+ result.getResponse().getReason());
+ }
+ }
+ };
+
public static Gson getGson() {
return (GSON);
}
return downloadImage("/bha-api/history.cgi?event=motionsensor&index=" + imageNumber);
}
+ public void sendAudio(InputStream audioInputStream) {
+ Authorization auth = authorization;
+ HttpClient client = httpClient;
+ if (client == null) {
+ logger.warn("Unable to send audio because httpClient is not set");
+ return;
+ }
+ if (auth == null) {
+ logAuthorizationError("audio-transmit");
+ return;
+ }
+ String url = buildUrl(auth, "/bha-api/audio-transmit.cgi");
+ logger.debug("Executing doorbird API post audio: {}", url);
+ DeferredContentProvider content = new DeferredContentProvider();
+ try {
+ // @formatter:off
+ client.POST(url)
+ .header("Authorization", "Basic " + auth.getAuthorization())
+ .header("Content-Type", "audio/basic")
+ .header("Content-Length", "9999999")
+ .header("Connection", "Keep-Alive")
+ .header("Cache-Control", "no-cache")
+ .content(content)
+ .send(complete);
+ // @formatter:on
+
+ // It is crucial to send data in small chunks to not overload the doorbird
+ // It means that we have to wait the appropriate amount of time between chunk to send
+ // real time data, as if it were live spoken.
+ int CHUNK_SIZE = 256;
+ int nbByteRead = -1;
+ long nextChunkSendTimeStamp = 0;
+ do {
+ byte[] data = new byte[CHUNK_SIZE];
+ nbByteRead = audioInputStream.read(data);
+ if (nbByteRead > 0) {
+ if (nbByteRead != CHUNK_SIZE) {
+ data = Arrays.copyOf(data, nbByteRead);
+ } // compute exact waiting time needed, by checking previous estimation against current time
+ long timeToWait = Math.max(0, nextChunkSendTimeStamp - System.currentTimeMillis());
+ Thread.sleep(timeToWait);
+ logger.debug("Sending chunk...");
+ content.offer(ByteBuffer.wrap(data));
+ }
+ nextChunkSendTimeStamp = System.currentTimeMillis() + 30;
+ } while (nbByteRead != -1);
+ } catch (InterruptedException | IOException e) {
+ logger.info("Unable to communicate with Doorbird", e);
+ } finally {
+ content.close();
+ }
+ }
+
public void openDoorController(String controllerId, String doorNumber) {
openDoor("/bha-api/open-door.cgi?r=" + controllerId + "@" + doorNumber);
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.doorbird.internal.audio;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.sound.sampled.AudioFormat;
+import javax.sound.sampled.AudioFormat.Encoding;
+import javax.sound.sampled.AudioInputStream;
+import javax.sound.sampled.AudioSystem;
+import javax.sound.sampled.UnsupportedAudioFileException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * This class convert a stream to the normalized ulaw
+ * format wanted by doorbird api
+ *
+ * @author Gwendal Roulleau - Initial contribution
+ */
+@NonNullByDefault
+public class ConvertedInputStream extends InputStream {
+
+ private static final javax.sound.sampled.AudioFormat INTERMEDIARY_PCM_FORMAT = new javax.sound.sampled.AudioFormat(
+ javax.sound.sampled.AudioFormat.Encoding.PCM_SIGNED, 8000, 16, 1, 2, 8000, false);
+ private static final javax.sound.sampled.AudioFormat TARGET_ULAW_FORMAT = new javax.sound.sampled.AudioFormat(
+ javax.sound.sampled.AudioFormat.Encoding.ULAW, 8000, 8, 1, 1, 8000, false);
+
+ private AudioInputStream pcmUlawInputStream;
+
+ public ConvertedInputStream(InputStream innerInputStream) throws UnsupportedAudioFileException, IOException {
+
+ pcmUlawInputStream = getULAWStream(new BufferedInputStream(innerInputStream));
+ }
+
+ public AudioInputStream getAudioInputStream() {
+ return pcmUlawInputStream;
+ }
+
+ @Override
+ public int read(byte @Nullable [] b) throws IOException {
+ return pcmUlawInputStream.read(b);
+ }
+
+ @Override
+ public int read(byte @Nullable [] b, int off, int len) throws IOException {
+ return pcmUlawInputStream.read(b, off, len);
+ }
+
+ @Override
+ public byte[] readAllBytes() throws IOException {
+ return pcmUlawInputStream.readAllBytes();
+ }
+
+ @Override
+ public byte[] readNBytes(int len) throws IOException {
+ return pcmUlawInputStream.readNBytes(len);
+ }
+
+ @Override
+ public int readNBytes(byte @Nullable [] b, int off, int len) throws IOException {
+ return pcmUlawInputStream.readNBytes(b, off, len);
+ }
+
+ @Override
+ public int read() throws IOException {
+ return pcmUlawInputStream.read();
+ }
+
+ @Override
+ public void close() throws IOException {
+ pcmUlawInputStream.close();
+ }
+
+ /**
+ * Ensure the right ULAW format by converting if necessary (two pass)
+ *
+ * @param originalInputStream a mark/reset compatible stream
+ *
+ * @return A ULAW stream (1 channel, 8000hz, 16 bit signed)
+ * @throws IOException
+ * @throws UnsupportedAudioFileException
+ */
+ private AudioInputStream getULAWStream(InputStream originalInputStream)
+ throws UnsupportedAudioFileException, IOException {
+
+ try {
+ AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(originalInputStream);
+ AudioFormat format = audioInputStream.getFormat();
+
+ boolean frameRateOk = Math.abs(format.getFrameRate() - 8000) < 1;
+ boolean sampleRateOk = Math.abs(format.getSampleRate() - 8000) < 1;
+
+ if (format.getEncoding().equals(Encoding.ULAW) && format.getChannels() == 1 && frameRateOk && sampleRateOk
+ && format.getFrameSize() == 1 && format.getSampleSizeInBits() == 8) {
+ return audioInputStream;
+ }
+
+ // we have to use an intermediary format with 16 bits, even if the final target format is 8 bits
+ // this is a limitation of the conversion library, which only accept 16 bits input to convert to ULAW.
+ AudioInputStream targetPCMFormat = audioInputStream;
+ if (format.getChannels() != 1 || !frameRateOk || !sampleRateOk || format.getFrameSize() != 2
+ || format.getSampleSizeInBits() != 16) {
+ targetPCMFormat = AudioSystem.getAudioInputStream(INTERMEDIARY_PCM_FORMAT, audioInputStream);
+ }
+
+ return AudioSystem.getAudioInputStream(TARGET_ULAW_FORMAT, targetPCMFormat);
+ } catch (IllegalArgumentException iarg) {
+ throw new UnsupportedAudioFileException(
+ "Cannot convert audio input to ULAW target format. Cause: " + iarg.getMessage());
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.doorbird.internal.audio;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+import javax.sound.sampled.UnsupportedAudioFileException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.doorbird.internal.handler.DoorbellHandler;
+import org.openhab.core.audio.AudioFormat;
+import org.openhab.core.audio.AudioSink;
+import org.openhab.core.audio.AudioStream;
+import org.openhab.core.audio.FixedLengthAudioStream;
+import org.openhab.core.audio.UnsupportedAudioFormatException;
+import org.openhab.core.audio.UnsupportedAudioStreamException;
+import org.openhab.core.library.types.PercentType;
+
+/**
+ * The audio sink for doorbird
+ *
+ * @author Gwendal Roulleau - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class DoorbirdAudioSink implements AudioSink {
+
+ private static final HashSet<AudioFormat> SUPPORTED_FORMATS = new HashSet<>();
+ private static final HashSet<Class<? extends AudioStream>> SUPPORTED_STREAMS = new HashSet<>();
+
+ private DoorbellHandler doorbellHandler;
+
+ static {
+ SUPPORTED_FORMATS.add(AudioFormat.WAV);
+ SUPPORTED_STREAMS.add(FixedLengthAudioStream.class);
+ }
+
+ public DoorbirdAudioSink(DoorbellHandler doorbellHandler) {
+ this.doorbellHandler = doorbellHandler;
+ }
+
+ @Override
+ public String getId() {
+ return doorbellHandler.getThing().getUID().toString();
+ }
+
+ @Override
+ public @Nullable String getLabel(@Nullable Locale locale) {
+ return doorbellHandler.getThing().getLabel();
+ }
+
+ @Override
+ public void process(@Nullable AudioStream audioStream)
+ throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
+ if (audioStream == null) {
+ return;
+ }
+ try (ConvertedInputStream normalizedULAWStream = new ConvertedInputStream(audioStream)) {
+ doorbellHandler.sendAudio(normalizedULAWStream);
+ } catch (UnsupportedAudioFileException | IOException e) {
+ throw new UnsupportedAudioFormatException("Cannot send to the doorbird sink", audioStream.getFormat(), e);
+ }
+ }
+
+ @Override
+ public Set<AudioFormat> getSupportedFormats() {
+ return SUPPORTED_FORMATS;
+ }
+
+ @Override
+ public Set<Class<? extends AudioStream>> getSupportedStreams() {
+ return SUPPORTED_STREAMS;
+ }
+
+ @Override
+ public PercentType getVolume() {
+ return new PercentType(100);
+ }
+
+ @Override
+ public void setVolume(PercentType volume) {
+ // NOT IMPLEMENTED
+ }
+}
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
+import java.util.Hashtable;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.openhab.binding.doorbird.internal.api.DoorbirdAPI;
import org.openhab.binding.doorbird.internal.api.DoorbirdImage;
import org.openhab.binding.doorbird.internal.api.SipStatus;
+import org.openhab.binding.doorbird.internal.audio.DoorbirdAudioSink;
import org.openhab.binding.doorbird.internal.config.DoorbellConfiguration;
import org.openhab.binding.doorbird.internal.listener.DoorbirdUdpListener;
+import org.openhab.core.audio.AudioSink;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private DoorbirdAPI api = new DoorbirdAPI();
+ private BundleContext bundleContext;
+
+ private @Nullable ServiceRegistration<AudioSink> audioSinkRegistration;
+
private final TimeZoneProvider timeZoneProvider;
private final HttpClient httpClient;
- public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient) {
+ public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient,
+ BundleContext bundleContext) {
super(thing);
this.timeZoneProvider = timeZoneProvider;
this.httpClient = httpClient;
+ this.bundleContext = bundleContext;
udpListener = new DoorbirdUdpListener(this);
}
api.setHttpClient(httpClient);
startImageRefreshJob();
startUDPListenerJob();
+ startAudioSink();
updateStatus(ThingStatus.ONLINE);
}
stopImageRefreshJob();
stopDoorbellOffJob();
stopMotionOffJob();
+ stopAudioSink();
super.dispose();
}
api.sipHangup();
}
+ public void sendAudio(InputStream inputStream) {
+ api.sendAudio(inputStream);
+ }
+
public String actionGetRingTimeLimit() {
return getSipStatusValue(SipStatus::getRingTimeLimit);
}
}
}
+ private void startAudioSink() {
+ final DoorbellHandler thisHandler = this;
+ // Register an audio sink in openhab
+ logger.trace("Registering an audio sink for this {}", thing.getUID());
+ audioSinkRegistration = bundleContext.registerService(AudioSink.class, new DoorbirdAudioSink(thisHandler),
+ new Hashtable<>());
+ }
+
+ private void stopAudioSink() {
+ // Unregister the doorbird audio sink
+ ServiceRegistration<AudioSink> audioSinkRegistrationLocal = audioSinkRegistration;
+ if (audioSinkRegistrationLocal != null) {
+ logger.trace("Unregistering the audio sync service for the doorbird thing {}", getThing().getUID());
+ audioSinkRegistrationLocal.unregister();
+ }
+ }
+
private void updateDoorbellMontage() {
if (config.montageNumImages == 0) {
return;