diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/DoorbirdHandlerFactory.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/DoorbirdHandlerFactory.java index c5e31f0d0eb..2a217c7743f 100644 --- a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/DoorbirdHandlerFactory.java +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/DoorbirdHandlerFactory.java @@ -58,7 +58,7 @@ public class DoorbirdHandlerFactory extends BaseThingHandlerFactory { 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); } diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdAPI.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdAPI.java index 47551dd0a23..51ba942577c 100644 --- a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdAPI.java +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdAPI.java @@ -13,8 +13,11 @@ 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; @@ -24,6 +27,9 @@ import org.eclipse.jdt.annotation.Nullable; 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; @@ -52,6 +58,17 @@ public final class DoorbirdAPI { 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); } @@ -145,6 +162,59 @@ public final class DoorbirdAPI { 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); } diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/audio/ConvertedInputStream.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/audio/ConvertedInputStream.java new file mode 100644 index 00000000000..e4ac1604bb1 --- /dev/null +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/audio/ConvertedInputStream.java @@ -0,0 +1,126 @@ +/** + * 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()); + } + } +} diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/audio/DoorbirdAudioSink.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/audio/DoorbirdAudioSink.java new file mode 100644 index 00000000000..d6c82b5c011 --- /dev/null +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/audio/DoorbirdAudioSink.java @@ -0,0 +1,98 @@ +/** + * 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 SUPPORTED_FORMATS = new HashSet<>(); + private static final HashSet> 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 getSupportedFormats() { + return SUPPORTED_FORMATS; + } + + @Override + public Set> getSupportedStreams() { + return SUPPORTED_STREAMS; + } + + @Override + public PercentType getVolume() { + return new PercentType(100); + } + + @Override + public void setVolume(PercentType volume) { + // NOT IMPLEMENTED + } +} diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/DoorbellHandler.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/DoorbellHandler.java index b831eaaf30d..e625fa8a5e4 100644 --- a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/DoorbellHandler.java +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/DoorbellHandler.java @@ -19,10 +19,12 @@ import java.awt.image.BufferedImage; 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; @@ -37,8 +39,10 @@ import org.openhab.binding.doorbird.internal.action.DoorbirdActions; 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; @@ -56,6 +60,8 @@ import org.openhab.core.types.Command; 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; @@ -88,13 +94,19 @@ public class DoorbellHandler extends BaseThingHandler { private DoorbirdAPI api = new DoorbirdAPI(); + private BundleContext bundleContext; + + private @Nullable ServiceRegistration 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); } @@ -120,6 +132,7 @@ public class DoorbellHandler extends BaseThingHandler { api.setHttpClient(httpClient); startImageRefreshJob(); startUDPListenerJob(); + startAudioSink(); updateStatus(ThingStatus.ONLINE); } @@ -129,6 +142,7 @@ public class DoorbellHandler extends BaseThingHandler { stopImageRefreshJob(); stopDoorbellOffJob(); stopMotionOffJob(); + stopAudioSink(); super.dispose(); } @@ -240,6 +254,10 @@ public class DoorbellHandler extends BaseThingHandler { api.sipHangup(); } + public void sendAudio(InputStream inputStream) { + api.sendAudio(inputStream); + } + public String actionGetRingTimeLimit() { return getSipStatusValue(SipStatus::getRingTimeLimit); } @@ -411,6 +429,23 @@ public class DoorbellHandler extends BaseThingHandler { } } + 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 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;