mirror of
https://github.com/danieldemus/openhab-core.git
synced 2026-07-29 12:34:22 +02:00
[audio] Add pcm audio websocket with dialog support (#4032)
* [audio] Add pcm audio websocket with dialog support * improve KSEdgeService support * fix/spotless voice provider Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
This commit is contained in:
@@ -286,6 +286,12 @@
|
||||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.websocket.audio</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.jetty.certificate</artifactId>
|
||||
|
||||
@@ -38,6 +38,18 @@
|
||||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.http</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.websocket</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.test</artifactId>
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.audio;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* This interface is designed so the voice bundle can inject the start dialog functionality for audio bundle to consume,
|
||||
* which a programmatic way of trigger the dialog execution.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface AudioDialogProvider {
|
||||
/**
|
||||
* Starts a dialog and returns a runnable that triggers it or null if the dialog initialization fails
|
||||
*
|
||||
* @param audioSink the audio sink to play sound
|
||||
* @param audioSource the audio source to capture sound
|
||||
* @param locationItem an optional Item name to scope dialog commands
|
||||
* @param listeningItem an optional Item name to toggle while dialog is listening, overwrites default
|
||||
* @param onAbort an optional {@link Runnable} instance to call on abort.
|
||||
* @return a {@link Runnable} instance to trigger dialog processing or null if the dialog initialization fails
|
||||
*/
|
||||
@Nullable
|
||||
Runnable startDialog(AudioSink audioSink, AudioSource audioSource, @Nullable String locationItem,
|
||||
@Nullable String listeningItem, @Nullable Runnable onAbort);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="target/classes" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="optional" value="true"/>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="annotationpath" value="target/dependency"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="annotationpath" value="target/dependency"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
|
||||
<attributes>
|
||||
<attribute name="optional" value="true"/>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="optional" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="test" value="true"/>
|
||||
<attribute name="optional" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="output" path="target/classes"/>
|
||||
</classpath>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>org.openhab.core.io.websocket.audio</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.m2e.core.maven2Builder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.m2e.core.maven2Nature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -0,0 +1,14 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-core
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.reactor.bundles</artifactId>
|
||||
<version>5.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.core.io.websocket.audio</artifactId>
|
||||
|
||||
<name>openHAB Core :: Bundles :: Audio PCM Websocket Interface</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.audio</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.websocket</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
|
||||
|
||||
import static java.nio.ByteBuffer.wrap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse;
|
||||
import org.openhab.core.audio.AudioDialogProvider;
|
||||
import org.openhab.core.audio.AudioManager;
|
||||
import org.openhab.core.common.ThreadPoolManager;
|
||||
import org.openhab.core.io.websocket.WebSocketAdapter;
|
||||
import org.osgi.framework.BundleContext;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Deactivate;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link PCMWebSocketAdapter} creates instances of {@link PCMWebSocketConnection} to handle PCM audio streams.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(immediate = true, service = { PCMWebSocketAdapter.class, WebSocketAdapter.class })
|
||||
public class PCMWebSocketAdapter implements WebSocketAdapter {
|
||||
public static final String ADAPTER_ID = "audio-pcm";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(PCMWebSocketAdapter.class);
|
||||
private final ScheduledExecutorService executor = ThreadPoolManager.getScheduledPool("audio-pcm-websocket");
|
||||
protected final BundleContext bundleContext;
|
||||
protected final AudioManager audioManager;
|
||||
protected final AudioDialogProvider audioDialogProvider;
|
||||
private final ScheduledFuture<?> pingTask;
|
||||
private final Set<PCMWebSocketConnection> webSocketConnections = Collections.synchronizedSet(new HashSet<>());
|
||||
|
||||
@Activate
|
||||
public PCMWebSocketAdapter(BundleContext bundleContext, final @Reference AudioManager audioManager,
|
||||
final @Reference AudioDialogProvider audioDialogProvider) {
|
||||
this.bundleContext = bundleContext;
|
||||
this.audioManager = audioManager;
|
||||
this.audioDialogProvider = audioDialogProvider;
|
||||
this.pingTask = executor.scheduleWithFixedDelay(this::pingHandlers, 10, 5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
protected void onSpeakerConnected(PCMWebSocketConnection speaker) throws IllegalStateException {
|
||||
synchronized (webSocketConnections) {
|
||||
if (getSpeakerConnection(speaker.getId()) != null) {
|
||||
throw new IllegalStateException("Another speaker with the same id is already connected");
|
||||
}
|
||||
webSocketConnections.add(speaker);
|
||||
logger.debug("connected speakers {}", webSocketConnections.size());
|
||||
}
|
||||
}
|
||||
|
||||
protected void onClientDisconnected(PCMWebSocketConnection connection) {
|
||||
logger.debug("speaker disconnected '{}'", connection.getId());
|
||||
synchronized (webSocketConnections) {
|
||||
webSocketConnections.remove(connection);
|
||||
logger.debug("connected speakers {}", webSocketConnections.size());
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable PCMWebSocketConnection getSpeakerConnection(String id) {
|
||||
synchronized (webSocketConnections) {
|
||||
return webSocketConnections.stream()
|
||||
.filter(speakerConnection -> speakerConnection.getId().equalsIgnoreCase(id)).findAny().orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return ADAPTER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object createWebSocket(ServletUpgradeRequest servletUpgradeRequest,
|
||||
ServletUpgradeResponse servletUpgradeResponse) {
|
||||
logger.debug("creating connection");
|
||||
return new PCMWebSocketConnection(this, executor);
|
||||
}
|
||||
|
||||
@Deactivate
|
||||
@SuppressWarnings("unused")
|
||||
public synchronized void deactivate() {
|
||||
logger.debug("stopping connection check");
|
||||
pingTask.cancel(true);
|
||||
disconnectAll();
|
||||
}
|
||||
|
||||
private void pingHandlers() {
|
||||
ArrayList<PCMWebSocketConnection> handlers = new ArrayList<>(webSocketConnections);
|
||||
for (var handler : handlers) {
|
||||
if (handler != null) {
|
||||
boolean pinged = false;
|
||||
var remote = handler.getRemote();
|
||||
if (remote != null) {
|
||||
try {
|
||||
remote.sendPing(wrap("oh".getBytes(StandardCharsets.UTF_8)));
|
||||
pinged = true;
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
if (!pinged) {
|
||||
logger.debug("Ping failed, disconnecting speaker '{}'", handler.getId());
|
||||
var session = handler.getSession();
|
||||
if (session != null) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private void disconnectAll() {
|
||||
logger.debug("Disconnecting {} clients...", webSocketConnections.size());
|
||||
ArrayList<PCMWebSocketConnection> connections = new ArrayList<>(webSocketConnections);
|
||||
for (var connection : connections) {
|
||||
onClientDisconnected(connection);
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.audio.*;
|
||||
import org.openhab.core.audio.utils.AudioWaveUtils;
|
||||
import org.openhab.core.library.types.PercentType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This is an {@link AudioSink} implementation connected to the {@link PCMWebSocketConnection} that allows to
|
||||
* transmit concurrent PCM audio lines through WebSocket.
|
||||
* <p>
|
||||
* To identify the different audio lines the data chucks are prefixed by a header added by the
|
||||
* {@link PCMWebSocketOutputStream} class.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class PCMWebSocketAudioSink implements AudioSink {
|
||||
/**
|
||||
* Byte sent after the last chunk for a stream to indicate the stream has ended, so the client can dispose resources
|
||||
* associated with the stream.
|
||||
* It's sent on a finally clause that covers the audio transmission execution so it gets sent even if some
|
||||
* exception interrupts the audio transmission.
|
||||
*/
|
||||
private static final byte STREAM_TERMINATION_BYTE = (byte) 254;
|
||||
private static final Set<AudioFormat> SUPPORTED_FORMATS = Set.of(AudioFormat.WAV, AudioFormat.PCM_SIGNED);
|
||||
private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Set.of(FixedLengthAudioStream.class,
|
||||
PipedAudioStream.class);
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(PCMWebSocketAudioSink.class);
|
||||
|
||||
private final String sinkId;
|
||||
private final String sinkLabel;
|
||||
private final PCMWebSocketConnection websocket;
|
||||
private PercentType sinkVolume = new PercentType(100);
|
||||
@Nullable
|
||||
private Integer forceSampleRate;
|
||||
@Nullable
|
||||
private Integer forceBitDepth;
|
||||
@Nullable
|
||||
private Integer forceChannels;
|
||||
|
||||
public PCMWebSocketAudioSink(String id, String label, PCMWebSocketConnection websocket,
|
||||
@Nullable Integer forceSampleRate, @Nullable Integer forceBitDepth, @Nullable Integer forceChannels) {
|
||||
this.sinkId = id;
|
||||
this.sinkLabel = label;
|
||||
this.websocket = websocket;
|
||||
this.forceSampleRate = forceSampleRate;
|
||||
this.forceBitDepth = forceBitDepth;
|
||||
this.forceChannels = forceChannels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return this.sinkId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String getLabel(@Nullable Locale locale) {
|
||||
return this.sinkLabel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void process(@Nullable AudioStream audioStream)
|
||||
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
|
||||
if (audioStream == null) {
|
||||
return;
|
||||
}
|
||||
OutputStream outputStream = null;
|
||||
try {
|
||||
long duration = -1;
|
||||
if (AudioFormat.CONTAINER_WAVE.equals(audioStream.getFormat().getContainer())) {
|
||||
logger.debug("Removing wav container from data");
|
||||
try {
|
||||
AudioWaveUtils.removeFMT(audioStream);
|
||||
} catch (IOException e) {
|
||||
logger.warn("IOException trying to remove wav header: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
var audioFormat = audioStream.getFormat();
|
||||
if (audioStream instanceof SizeableAudioStream sizeableAudioStream) {
|
||||
long byteLength = sizeableAudioStream.length();
|
||||
long bytesPerSecond = (Objects.requireNonNull(audioFormat.getBitDepth()) / 8)
|
||||
* Objects.requireNonNull(audioFormat.getFrequency())
|
||||
* Objects.requireNonNull(audioFormat.getChannels());
|
||||
float durationInSeconds = (float) byteLength / bytesPerSecond;
|
||||
duration = Math.round(durationInSeconds * 1000);
|
||||
logger.debug("Duration of input stream : {}", duration);
|
||||
}
|
||||
AtomicBoolean transferenceAborted = new AtomicBoolean(false);
|
||||
if (audioStream instanceof PipedAudioStream pipedAudioStream) {
|
||||
pipedAudioStream.onClose(() -> transferenceAborted.set(true));
|
||||
}
|
||||
int sampleRate = Objects.requireNonNull(audioFormat.getFrequency()).intValue();
|
||||
int bitDepth = Objects.requireNonNull(audioFormat.getBitDepth());
|
||||
int channels = Objects.requireNonNull(audioFormat.getChannels());
|
||||
int targetSampleRate = Objects.requireNonNullElse(forceSampleRate, sampleRate);
|
||||
Integer targetBitDepth = Objects.requireNonNullElse(forceBitDepth, bitDepth);
|
||||
Integer targetChannels = Objects.requireNonNullElse(forceChannels, channels);
|
||||
outputStream = new PCMWebSocketOutputStream(websocket, targetSampleRate, targetBitDepth.byteValue(),
|
||||
targetChannels.byteValue());
|
||||
InputStream finalAudioStream;
|
||||
if ( //
|
||||
(forceSampleRate != null && !forceSampleRate.equals(sampleRate)) || //
|
||||
(forceBitDepth != null && !forceBitDepth.equals(bitDepth)) || //
|
||||
(forceChannels != null && !forceChannels.equals(channels)) //
|
||||
) {
|
||||
logger.debug("Sound is not in the target format. Trying to re-encode it");
|
||||
finalAudioStream = PCMWebSocketAudioUtil.getPCMStreamNormalized(audioStream, sampleRate, bitDepth,
|
||||
channels, targetSampleRate, targetBitDepth, targetChannels);
|
||||
} else {
|
||||
finalAudioStream = audioStream;
|
||||
}
|
||||
int bytesPer500ms = (targetSampleRate * (targetBitDepth / 8) * targetChannels) / 2;
|
||||
transferAudio(finalAudioStream, outputStream, bytesPer500ms, duration, transferenceAborted);
|
||||
} catch (InterruptedIOException ignored) {
|
||||
} catch (IOException e) {
|
||||
logger.warn("IOException: {}", e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
logger.warn("InterruptedException: {}", e.getMessage());
|
||||
} finally {
|
||||
try {
|
||||
audioStream.close();
|
||||
} catch (IOException e) {
|
||||
logger.warn("IOException: {}", e.getMessage(), e);
|
||||
}
|
||||
try {
|
||||
if (outputStream != null) {
|
||||
outputStream.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("IOException: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void transferAudio(InputStream inputStream, OutputStream outputStream, int chunkSize, long duration,
|
||||
AtomicBoolean aborted) throws IOException, InterruptedException {
|
||||
Instant start = Instant.now();
|
||||
long transferred = 0;
|
||||
try {
|
||||
byte[] buffer = new byte[chunkSize];
|
||||
int read;
|
||||
while (!aborted.get() && (read = inputStream.read(buffer, 0, chunkSize)) >= 0) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
transferred += read;
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// send a byte indicating this stream has ended, so it can be tear down on the client
|
||||
outputStream.write(new byte[] { STREAM_TERMINATION_BYTE }, 0, 1);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Unable to send termination byte to sink {}", sinkId);
|
||||
}
|
||||
}
|
||||
logger.debug("Sent {} bytes of audio", transferred);
|
||||
if (duration != -1) {
|
||||
Instant end = Instant.now();
|
||||
long millisSecondTimedToSendAudioData = Duration.between(start, end).toMillis();
|
||||
if (millisSecondTimedToSendAudioData < duration) {
|
||||
long timeToSleep = duration - millisSecondTimedToSendAudioData;
|
||||
logger.debug("Sleep time to let the system play sound : {}ms", timeToSleep);
|
||||
Thread.sleep(timeToSleep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<AudioFormat> getSupportedFormats() {
|
||||
return SUPPORTED_FORMATS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Class<? extends AudioStream>> getSupportedStreams() {
|
||||
return SUPPORTED_STREAMS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PercentType getVolume() throws IOException {
|
||||
return this.sinkVolume;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setVolume(PercentType percentType) throws IOException {
|
||||
this.sinkVolume = percentType;
|
||||
websocket.setSinkVolume(percentType.intValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an {@link OutputStream} implementation for writing binary data to the websocket that
|
||||
* will prefix each chunk with a header composed of 8 bytes.
|
||||
* Header: 2 bytes (stream id) + 4 byte (stream sample rate) + 1 byte (stream bit depth) + 1 byte (channels).
|
||||
*/
|
||||
protected static class PCMWebSocketOutputStream extends OutputStream {
|
||||
private final byte[] header;
|
||||
private final PCMWebSocketConnection websocket;
|
||||
private boolean closed = false;
|
||||
|
||||
public PCMWebSocketOutputStream(PCMWebSocketConnection websocket, int sampleRate, byte bitDepth,
|
||||
byte channels) {
|
||||
this.websocket = websocket;
|
||||
this.header = PCMWebSocketStreamIdUtil.generateAudioPacketHeader(sampleRate, bitDepth, channels).array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
write(ByteBuffer.allocate(4).putInt(b).array());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte @Nullable [] b) throws IOException {
|
||||
if (closed) {
|
||||
throw new IOException("Stream closed");
|
||||
}
|
||||
if (b != null) {
|
||||
websocket.sendAudio(header, b);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte @Nullable [] b, int off, int len) throws IOException {
|
||||
if (b != null) {
|
||||
write(Arrays.copyOfRange(b, off, off + len));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
closed = true;
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.audio.*;
|
||||
import org.openhab.core.common.ThreadPoolManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This is an {@link AudioSource} implementation connected to a {@link PCMWebSocketConnection} supporting
|
||||
* a single PCM audio line through a WebSocket connection and shared across all active {@link AudioStream} instances.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class PCMWebSocketAudioSource implements AudioSource {
|
||||
private static final int SUPPORTED_BIT_DEPTH = 16;
|
||||
private static final int SUPPORTED_SAMPLE_RATE = 16000;
|
||||
private static final int SUPPORTED_CHANNELS = 1;
|
||||
public static AudioFormat supportedFormat = new AudioFormat(AudioFormat.CONTAINER_WAVE,
|
||||
AudioFormat.CODEC_PCM_SIGNED, false, SUPPORTED_BIT_DEPTH, null, (long) SUPPORTED_SAMPLE_RATE,
|
||||
SUPPORTED_CHANNELS);
|
||||
private final Logger logger = LoggerFactory.getLogger(PCMWebSocketAudioSource.class);
|
||||
private final String sourceId;
|
||||
private final String sourceLabel;
|
||||
private final PCMWebSocketConnection websocket;
|
||||
private @Nullable PipedOutputStream sourceAudioPipedOutput;
|
||||
private @Nullable PipedInputStream sourceAudioPipedInput;
|
||||
private @Nullable InputStream sourceAudioStream;
|
||||
private final PipedAudioStream.Group streamGroup = PipedAudioStream.newGroup(supportedFormat);
|
||||
private @Nullable Future<?> sourceWriteTask;
|
||||
private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool("pcm-audio-source");
|
||||
private byte @Nullable [] streamId;
|
||||
|
||||
public PCMWebSocketAudioSource(String id, String label, PCMWebSocketConnection websocket) {
|
||||
this.sourceId = id;
|
||||
this.sourceLabel = label;
|
||||
this.websocket = websocket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return this.sourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel(@Nullable Locale locale) {
|
||||
return this.sourceLabel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<AudioFormat> getSupportedFormats() {
|
||||
return Set.of(supportedFormat);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AudioStream getInputStream(AudioFormat audioFormat) throws AudioException {
|
||||
try {
|
||||
final PipedAudioStream stream = streamGroup.getAudioStreamInGroup();
|
||||
synchronized (streamGroup) {
|
||||
if (this.streamGroup.size() == 1) {
|
||||
logger.debug("Send start listening {}", getId());
|
||||
this.streamId = null;
|
||||
websocket.setListening(true);
|
||||
}
|
||||
}
|
||||
stream.onClose(this::onStreamClose);
|
||||
return stream;
|
||||
} catch (IOException e) {
|
||||
throw new AudioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void close() throws Exception {
|
||||
streamGroup.close();
|
||||
}
|
||||
|
||||
public void writeToStreams(byte[] id, int sampleRate, int bitDepth, int channels, byte[] payload) {
|
||||
if (streamGroup.isEmpty()) {
|
||||
logger.debug("Source already disposed, ignoring data");
|
||||
return;
|
||||
}
|
||||
if (this.streamId == null) {
|
||||
this.streamId = id;
|
||||
} else if (!Arrays.equals(this.streamId, id)) {
|
||||
logger.warn("Only one concurrent data line is supported, ignoring data from source stream {}", id);
|
||||
return;
|
||||
}
|
||||
boolean needsConvert = sampleRate != SUPPORTED_SAMPLE_RATE || bitDepth != SUPPORTED_BIT_DEPTH
|
||||
|| channels != SUPPORTED_CHANNELS;
|
||||
if (!needsConvert) {
|
||||
streamGroup.write(payload);
|
||||
return;
|
||||
}
|
||||
if (this.sourceAudioPipedOutput == null || this.sourceAudioStream == null) {
|
||||
try {
|
||||
this.sourceAudioPipedOutput = new PipedOutputStream();
|
||||
var sourceAudioPipedInput = this.sourceAudioPipedInput = new PipedInputStream(
|
||||
this.sourceAudioPipedOutput, (sampleRate * (bitDepth / 8) * channels) * 2);
|
||||
logger.debug(
|
||||
"Enabling converting pcm audio for the audio source stream: sample rate {}, bit depth {}, channels {} => sample rate {}, bit depth {}, channels {}",
|
||||
sampleRate, bitDepth, channels, SUPPORTED_SAMPLE_RATE, SUPPORTED_BIT_DEPTH, SUPPORTED_CHANNELS);
|
||||
this.sourceAudioStream = PCMWebSocketAudioUtil.getPCMStreamNormalized(sourceAudioPipedInput, sampleRate,
|
||||
bitDepth, channels, SUPPORTED_SAMPLE_RATE, SUPPORTED_BIT_DEPTH, SUPPORTED_CHANNELS);
|
||||
sourceWriteTask = scheduler.submit(() -> {
|
||||
int bytesPer250ms = (SUPPORTED_SAMPLE_RATE * (SUPPORTED_BIT_DEPTH / 8) * SUPPORTED_CHANNELS) / 4;
|
||||
while (true) {
|
||||
byte[] convertedPayload;
|
||||
try {
|
||||
convertedPayload = this.sourceAudioStream.readNBytes(bytesPer250ms);
|
||||
Thread.sleep(0);
|
||||
} catch (InterruptedIOException | InterruptedException e) {
|
||||
continue;
|
||||
} catch (IOException e) {
|
||||
if (e.getMessage().contains("Pipe closed")) {
|
||||
return;
|
||||
}
|
||||
logger.error("Error reading converted audio data", e);
|
||||
continue;
|
||||
}
|
||||
streamGroup.write(convertedPayload);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
logger.error("Unable to setup audio source stream", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.sourceAudioPipedOutput.write(payload);
|
||||
} catch (IOException e) {
|
||||
logger.error("Error converting source audio format", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void onStreamClose() {
|
||||
logger.debug("Unregister source audio stream for '{}'", getId());
|
||||
synchronized (streamGroup) {
|
||||
if (streamGroup.isEmpty()) {
|
||||
logger.debug("Send stop listening {}", getId());
|
||||
websocket.setListening(false);
|
||||
if (this.sourceWriteTask != null) {
|
||||
this.sourceWriteTask.cancel(true);
|
||||
this.sourceWriteTask = null;
|
||||
}
|
||||
if (this.sourceAudioStream != null) {
|
||||
try {
|
||||
this.sourceAudioStream.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
this.sourceAudioStream = null;
|
||||
}
|
||||
if (this.sourceAudioPipedOutput != null) {
|
||||
try {
|
||||
this.sourceAudioPipedOutput.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
this.sourceAudioPipedOutput = null;
|
||||
}
|
||||
if (this.sourceAudioPipedInput != null) {
|
||||
try {
|
||||
this.sourceAudioPipedInput.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
this.sourceAudioPipedInput = null;
|
||||
}
|
||||
this.streamId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Audio utils.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class PCMWebSocketAudioUtil {
|
||||
private PCMWebSocketAudioUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure right PCM format by converting if needed (sample rate, channel)
|
||||
*
|
||||
* @param sampleRate Stream sample rate
|
||||
* @param stream PCM input stream
|
||||
* @return A PCM normalized stream at the desired format
|
||||
*/
|
||||
public static AudioInputStream getPCMStreamNormalized(InputStream stream, int sampleRate, int bitDepth,
|
||||
int channels, int targetSampleRate, int targetBitDepth, int targetChannels) {
|
||||
javax.sound.sampled.AudioFormat jFormat = new javax.sound.sampled.AudioFormat( //
|
||||
(float) sampleRate, //
|
||||
bitDepth, //
|
||||
channels, //
|
||||
true, //
|
||||
false //
|
||||
);
|
||||
javax.sound.sampled.AudioFormat fixedJFormat = new javax.sound.sampled.AudioFormat( //
|
||||
(float) targetSampleRate, //
|
||||
targetBitDepth, //
|
||||
targetChannels, //
|
||||
true, //
|
||||
false //
|
||||
);
|
||||
return AudioSystem.getAudioInputStream(fixedJFormat, new AudioInputStream(stream, jFormat, -1));
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.HashMap;
|
||||
import java.util.Hashtable;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.websocket.api.RemoteEndpoint;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketListener;
|
||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||
import org.openhab.core.audio.AudioSink;
|
||||
import org.openhab.core.audio.AudioSource;
|
||||
import org.osgi.framework.ServiceRegistration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* The {@link PCMWebSocketConnection} represents a WebSocket connection used to transmit PCM audio.
|
||||
* <p>
|
||||
* The websocket uses the text protocol for send commands represented by {@link WebSocketCommand} and the binary
|
||||
* protocol to transmit the audio data.
|
||||
* <p>
|
||||
* The websocket supports only one line for the {@link PCMWebSocketAudioSource} (the audio is shared on the server),
|
||||
* the data transmission is instructed by the server (START_LISTENING and STOP_LISTENING commands).
|
||||
* <p>
|
||||
* The websocket supports multiple lines for the {@link PCMWebSocketAudioSink} (to accomplish that, the outgoing data
|
||||
* chucks are prefixed with a 6 byte header to transmit the identity and format specification, check
|
||||
* {@link PCMWebSocketAudioSink.PCMWebSocketOutputStream})
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@WebSocket
|
||||
@NonNullByDefault
|
||||
@SuppressWarnings("unused")
|
||||
public class PCMWebSocketConnection implements WebSocketListener {
|
||||
private final Logger logger = LoggerFactory.getLogger(PCMWebSocketConnection.class);
|
||||
protected final Map<String, ServiceRegistration<?>> audioComponentRegistrations = new ConcurrentHashMap<>();
|
||||
private volatile @Nullable Session session;
|
||||
private @Nullable RemoteEndpoint remote;
|
||||
private final PCMWebSocketAdapter wsAdapter;
|
||||
private final ScheduledExecutorService executor;
|
||||
private @Nullable ScheduledFuture<?> scheduledDisconnection;
|
||||
|
||||
private boolean initialized = false;
|
||||
private @Nullable Runnable dialogTrigger = null;
|
||||
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
private String id = "";
|
||||
private @Nullable PCMWebSocketAudioSource audioSource = null;
|
||||
|
||||
public PCMWebSocketConnection(PCMWebSocketAdapter wsAdapter, ScheduledExecutorService executor) {
|
||||
this.wsAdapter = wsAdapter;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
public void sendAudio(byte[] id, byte[] b) {
|
||||
try {
|
||||
var remote = getRemote();
|
||||
if (remote != null) {
|
||||
// concat stream identifier and send
|
||||
ByteBuffer buff = ByteBuffer.wrap(new byte[id.length + b.length]);
|
||||
buff.put(id);
|
||||
buff.put(b);
|
||||
remote.sendBytesByFuture(ByteBuffer.wrap(buff.array()));
|
||||
}
|
||||
} catch (IllegalStateException ignored) {
|
||||
logger.warn("Unable to send audio buffer");
|
||||
}
|
||||
}
|
||||
|
||||
public void setListening(boolean listening) {
|
||||
sendClientCommand(new WebSocketCommand(listening ? WebSocketCommand.OutputCommands.START_LISTENING
|
||||
: WebSocketCommand.OutputCommands.STOP_LISTENING));
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
var session = getSession();
|
||||
if (session != null) {
|
||||
try {
|
||||
session.disconnect();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWebSocketConnect(@Nullable Session sess) {
|
||||
if (sess == null) {
|
||||
// never
|
||||
return;
|
||||
}
|
||||
this.session = sess;
|
||||
this.remote = sess.getRemote();
|
||||
logger.debug("New client connected.");
|
||||
scheduledDisconnection = executor.schedule(() -> {
|
||||
try {
|
||||
sess.disconnect();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}, 5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private <T extends WebSocketCommand> void sendClientCommand(T msg) {
|
||||
var remote = getRemote();
|
||||
if (remote != null) {
|
||||
try {
|
||||
remote.sendStringByFuture(new ObjectMapper().writeValueAsString(msg));
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("JsonProcessingException writing JSON message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWebSocketBinary(byte @Nullable [] payload, int offset, int len) {
|
||||
logger.trace("Received binary data of length {}", len);
|
||||
PCMWebSocketAudioSource audioSource = this.audioSource;
|
||||
if (payload != null && audioSource != null) {
|
||||
PCMWebSocketStreamIdUtil.AudioPacketData streamData;
|
||||
try {
|
||||
streamData = PCMWebSocketStreamIdUtil.parseAudioPacket(payload);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Exception processing binary message: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
audioSource.writeToStreams(streamData.id(), streamData.sampleRate(), streamData.bitDepth(),
|
||||
streamData.channels(), streamData.audioData());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWebSocketText(@Nullable String message) {
|
||||
try {
|
||||
JsonNode rootMessageNode = jsonMapper.readTree(message);
|
||||
if (rootMessageNode.has("cmd")) {
|
||||
String cmd = rootMessageNode.get("cmd").asText().trim().toUpperCase();
|
||||
try {
|
||||
logger.debug("Handling msg '{}'", cmd);
|
||||
var messageType = WebSocketCommand.InputCommands.valueOf(cmd);
|
||||
switch (messageType) {
|
||||
case INITIALIZE -> {
|
||||
wsAdapter.onSpeakerConnected(this);
|
||||
JsonNode argsNode = rootMessageNode.get("args");
|
||||
var clientOptions = jsonMapper.treeToValue(argsNode, ConnectionOptions.class);
|
||||
var scheduledDisconnection = this.scheduledDisconnection;
|
||||
if (scheduledDisconnection != null) {
|
||||
scheduledDisconnection.cancel(true);
|
||||
}
|
||||
// update connection settings
|
||||
id = clientOptions.id;
|
||||
registerSpeakerComponents(id, clientOptions);
|
||||
sendClientCommand(new WebSocketCommand(WebSocketCommand.OutputCommands.INITIALIZED));
|
||||
}
|
||||
case ON_SPOT -> onRemoteSpot();
|
||||
}
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
logger.warn("Error handing command '{}' with message: {}. Disconnecting client", cmd,
|
||||
e.getMessage());
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("Exception parsing JSON message.", e);
|
||||
logger.warn("Disconnecting client.");
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWebSocketError(@Nullable Throwable cause) {
|
||||
logger.warn("WebSocket Error", cause);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWebSocketClose(int statusCode, @Nullable String reason) {
|
||||
this.session = null;
|
||||
this.remote = null;
|
||||
logger.debug("Session closed with code {}: {}", statusCode, reason);
|
||||
wsAdapter.onClientDisconnected(this);
|
||||
unregisterSpeakerComponents(id);
|
||||
}
|
||||
|
||||
public void setSinkVolume(int value) {
|
||||
if (initialized) {
|
||||
sendClientCommand(
|
||||
new WebSocketCommand(WebSocketCommand.OutputCommands.SINK_VOLUME, Map.of("value", value)));
|
||||
}
|
||||
}
|
||||
|
||||
public void setSourceVolume(int value) {
|
||||
if (initialized) {
|
||||
sendClientCommand(
|
||||
new WebSocketCommand(WebSocketCommand.OutputCommands.SOURCE_VOLUME, Map.of("value", value)));
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable RemoteEndpoint getRemote() {
|
||||
return this.remote;
|
||||
}
|
||||
|
||||
public @Nullable Session getSession() {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
Session sess = this.session;
|
||||
return sess != null && sess.isOpen();
|
||||
}
|
||||
|
||||
private synchronized void registerSpeakerComponents(String id, ConnectionOptions clientOptions) throws IOException {
|
||||
if (id.isBlank()) {
|
||||
throw new IOException("Unable to register audio components");
|
||||
}
|
||||
String label = "PCM Audio WebSocket (" + id + ")";
|
||||
logger.debug("Registering dialog components for '{}'", id);
|
||||
this.initialized = true;
|
||||
// register source
|
||||
var audioSource = this.audioSource = new PCMWebSocketAudioSource(getSourceId(id), label, this);
|
||||
logger.debug("Registering audio source {}", this.audioSource.getId());
|
||||
audioComponentRegistrations.put(this.audioSource.getId(), wsAdapter.bundleContext
|
||||
.registerService(AudioSource.class.getName(), this.audioSource, new Hashtable<>()));
|
||||
// register sink
|
||||
var audioSink = new PCMWebSocketAudioSink(getSinkId(id), label, this, clientOptions.forceSampleRate,
|
||||
clientOptions.forceBitDepth, clientOptions.forceChannels);
|
||||
logger.debug("Registering audio sink {}", audioSink.getId());
|
||||
audioComponentRegistrations.put(audioSink.getId(),
|
||||
wsAdapter.bundleContext.registerService(AudioSink.class.getName(), audioSink, new Hashtable<>()));
|
||||
// init dialog
|
||||
if (clientOptions.startDialog) {
|
||||
var dialogProvider = this.wsAdapter.audioDialogProvider;
|
||||
if (dialogProvider == null) {
|
||||
throw new IOException("Voice functionality is not ready");
|
||||
}
|
||||
dialogTrigger = dialogProvider.startDialog(audioSink, audioSource,
|
||||
!clientOptions.locationItem.isBlank() ? clientOptions.locationItem : null,
|
||||
!clientOptions.listeningItem.isBlank() ? clientOptions.listeningItem : null, () -> disconnect());
|
||||
} else {
|
||||
dialogTrigger = null;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void unregisterSpeakerComponents(String id) {
|
||||
initialized = false;
|
||||
dialogTrigger = null;
|
||||
var source = wsAdapter.audioManager.getSource(getSourceId(id));
|
||||
if (source instanceof PCMWebSocketAudioSource hsAudioSource) {
|
||||
try {
|
||||
hsAudioSource.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (source != null) {
|
||||
ServiceRegistration<?> sourceReg = audioComponentRegistrations.remove(source.getId());
|
||||
if (sourceReg != null) {
|
||||
logger.debug("Unregistering audio source {}", source.getId());
|
||||
sourceReg.unregister();
|
||||
}
|
||||
}
|
||||
var sink = wsAdapter.audioManager.getSink(getSinkId(id));
|
||||
if (sink != null) {
|
||||
ServiceRegistration<?> sinkReg = audioComponentRegistrations.remove(sink.getId());
|
||||
if (sinkReg != null) {
|
||||
logger.debug("Unregistering audio sink {}", sink.getId());
|
||||
sinkReg.unregister();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onRemoteSpot() {
|
||||
var dialogTrigger = this.dialogTrigger;
|
||||
if (dialogTrigger != null) {
|
||||
dialogTrigger.run();
|
||||
}
|
||||
}
|
||||
|
||||
private String getSinkId(String id) {
|
||||
return "pcm::" + id + "::sink";
|
||||
}
|
||||
|
||||
private String getSourceId(String id) {
|
||||
return "pcm::" + id + "::source";
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
private static class WebSocketCommand {
|
||||
public String cmd = "";
|
||||
public Map<String, Object> args;
|
||||
|
||||
public WebSocketCommand(OutputCommands cmd) {
|
||||
this(cmd, new HashMap<>());
|
||||
}
|
||||
|
||||
public WebSocketCommand(OutputCommands cmd, Map<String, Object> args) {
|
||||
this.cmd = cmd.name();
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
public enum OutputCommands {
|
||||
INITIALIZED,
|
||||
START_LISTENING,
|
||||
STOP_LISTENING,
|
||||
SINK_VOLUME,
|
||||
SOURCE_VOLUME,
|
||||
}
|
||||
|
||||
public enum InputCommands {
|
||||
INITIALIZE,
|
||||
ON_SPOT,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link ConnectionOptions} represents the options provided by the ws client.
|
||||
*/
|
||||
public static class ConnectionOptions {
|
||||
/**
|
||||
* Identifier to concatenate to related services (dialog, source and sick)
|
||||
*/
|
||||
public String id = "";
|
||||
/**
|
||||
* Force sink audio sample rate (resample in server)
|
||||
*/
|
||||
public @Nullable Integer forceSampleRate;
|
||||
/**
|
||||
* Force sink audio bit depth (resample in server)
|
||||
*/
|
||||
public @Nullable Integer forceBitDepth;
|
||||
/**
|
||||
* Force sink audio channels (resample in server)
|
||||
*/
|
||||
public @Nullable Integer forceChannels;
|
||||
/**
|
||||
* Start a dialog processor using the registered audio components
|
||||
*/
|
||||
public boolean startDialog = false;
|
||||
/**
|
||||
* Listening item for the dialog
|
||||
*/
|
||||
public String listeningItem = "";
|
||||
/**
|
||||
* Location item for the dialog
|
||||
*/
|
||||
public String locationItem = "";
|
||||
|
||||
public ConnectionOptions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.io.websocket.audio.internal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Utils to read/write the audio packets send though the websocket binary protocol.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class PCMWebSocketStreamIdUtil {
|
||||
private PCMWebSocketStreamIdUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Packet header length in bytes:
|
||||
* <ul>
|
||||
* <li>2 for id</li>
|
||||
* <li>4 for sample rate as int little-endian</li>
|
||||
* <li>1 for bitDepth</li>
|
||||
* <li>1 for channels</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static final int PACKET_HEADER_BYTE_LENGTH = 2 + 4 + 1 + 1;
|
||||
|
||||
public static AudioPacketData parseAudioPacket(byte[] bytes) throws IOException {
|
||||
if (bytes.length < PACKET_HEADER_BYTE_LENGTH) {
|
||||
throw new IOException("Audio packet byte length is too small, invalid data.");
|
||||
}
|
||||
var byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
byte[] idBytes = new byte[] { byteBuffer.get(), byteBuffer.get() };
|
||||
int sampleRate = byteBuffer.getInt();
|
||||
byte bitDepth = byteBuffer.get();
|
||||
byte channels = byteBuffer.get();
|
||||
byte[] audioBytes = new byte[byteBuffer.remaining()];
|
||||
byteBuffer.get(audioBytes);
|
||||
return new AudioPacketData(idBytes, sampleRate, bitDepth, channels, audioBytes);
|
||||
}
|
||||
|
||||
public static ByteBuffer generateAudioPacketHeader(int sampleRate, byte bitDepth, byte channels) {
|
||||
var byteBuffer = ByteBuffer.allocate(PACKET_HEADER_BYTE_LENGTH).order(ByteOrder.LITTLE_ENDIAN);
|
||||
SecureRandom sr = new SecureRandom();
|
||||
byte[] rndBytes = new byte[2];
|
||||
sr.nextBytes(rndBytes);
|
||||
byteBuffer.put(rndBytes[0]);
|
||||
byteBuffer.put(rndBytes[1]);
|
||||
byteBuffer.putInt(sampleRate);
|
||||
byteBuffer.put(bitDepth);
|
||||
byteBuffer.put(channels);
|
||||
return byteBuffer;
|
||||
}
|
||||
|
||||
public record AudioPacketData(byte[] id, int sampleRate, byte bitDepth, byte channels, byte[] audioData) {
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* This is the interface for a dialog trigger service that only allows to register a {@link DTEvent} listener.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface BasicDTService extends DTService {
|
||||
/**
|
||||
* Used to register the dialog trigger events listener
|
||||
*
|
||||
* @param dtListener Non-null {@link DTListener} that {@link DTEvent} events target
|
||||
* @throws DTException if the listener cannot be registered due to an internal error or invalid state
|
||||
*/
|
||||
DTServiceHandle registerListener(DTListener dtListener) throws DTException;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* A {@link DTEvent} fired when the {@link DTService} encounters an error.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DTErrorEvent implements DTEvent {
|
||||
/**
|
||||
* The message describing the error
|
||||
*/
|
||||
private final String message;
|
||||
|
||||
/**
|
||||
* Constructs an instance with the passed {@code message}.
|
||||
*
|
||||
* @param message The message describing the error
|
||||
*/
|
||||
public DTErrorEvent(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message describing this error
|
||||
*
|
||||
* @return The message describing this error
|
||||
*/
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* A tagging interface for dialog trigger events.
|
||||
*
|
||||
* This interface is intended to represent events that can trigger a dialog,
|
||||
* without being specific to any particular type of event such as keyword spotting.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DTEvent {
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* General purpose dialog trigger exception
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DTException extends Exception {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* Constructs a new exception with null as its detail message.
|
||||
*/
|
||||
public DTException() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new exception with the specified detail message and cause.
|
||||
*
|
||||
* @param message Detail message
|
||||
* @param cause The cause
|
||||
*/
|
||||
public DTException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new exception with the specified detail message.
|
||||
*
|
||||
* @param message Detail message
|
||||
*/
|
||||
public DTException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new exception with the specified cause.
|
||||
*
|
||||
* @param cause The cause
|
||||
*/
|
||||
public DTException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The listener interface for receiving {@link DTEvent} events.
|
||||
*
|
||||
* A class interested in processing {@link DTEvent} events implements this interface.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DTListener {
|
||||
/**
|
||||
* Invoked when a {@link DTEvent} event occurs.
|
||||
*
|
||||
* @param dtEvent The {@link DTEvent} fired by the {@link DTService}
|
||||
*/
|
||||
void dtEventReceived(DTEvent dtEvent);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* This is the interface that a dialog trigger service has to implement.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DTService {
|
||||
/**
|
||||
* Returns a simple string that uniquely identifies this service
|
||||
*
|
||||
* @return an id that identifies this service
|
||||
*/
|
||||
String getId();
|
||||
|
||||
/**
|
||||
* Returns a localized human-readable label that can be used within UIs.
|
||||
*
|
||||
* @param locale the locale to provide the label for
|
||||
* @return a localized string to be used in UIs
|
||||
*/
|
||||
String getLabel(@Nullable Locale locale);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* A handle to a {@link DTService}.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface DTServiceHandle {
|
||||
/**
|
||||
* Aborts execution in the associated {@link DTServiceHandle}
|
||||
*/
|
||||
void abort();
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* A {@link DTEvent} fired when the {@link DTService} wants to trigger the dialog.
|
||||
*
|
||||
* @author Miguel Álvarez Díez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DTTriggeredEvent implements DTEvent {
|
||||
}
|
||||
+12
-5
@@ -29,7 +29,7 @@ import org.openhab.core.voice.text.HumanLanguageInterpreter;
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public record DialogContext(@Nullable KSService ks, @Nullable String keyword, STTService stt, TTSService tts,
|
||||
public record DialogContext(@Nullable DTService dt, @Nullable String keyword, STTService stt, TTSService tts,
|
||||
@Nullable Voice voice, List<HumanLanguageInterpreter> hlis, AudioSource source, AudioSink sink, Locale locale,
|
||||
String dialogGroup, @Nullable String locationItem, @Nullable String listeningItem,
|
||||
@Nullable String listeningMelody) {
|
||||
@@ -42,7 +42,7 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
|
||||
// services
|
||||
private @Nullable AudioSource source;
|
||||
private @Nullable AudioSink sink;
|
||||
private @Nullable KSService ks;
|
||||
private @Nullable DTService dt;
|
||||
private @Nullable STTService stt;
|
||||
private @Nullable TTSService tts;
|
||||
private @Nullable Voice voice;
|
||||
@@ -72,7 +72,14 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
|
||||
|
||||
public Builder withKS(@Nullable KSService service) {
|
||||
if (service != null) {
|
||||
this.ks = service;
|
||||
this.dt = service;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withDT(@Nullable DTService service) {
|
||||
if (service != null) {
|
||||
this.dt = service;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -165,7 +172,7 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
|
||||
* @throws IllegalStateException if a required dialog component is missing
|
||||
*/
|
||||
public DialogContext build() throws IllegalStateException {
|
||||
KSService ksService = ks;
|
||||
DTService dtService = dt;
|
||||
STTService sttService = stt;
|
||||
TTSService ttsService = tts;
|
||||
List<HumanLanguageInterpreter> hliServices = hlis;
|
||||
@@ -191,7 +198,7 @@ public record DialogContext(@Nullable KSService ks, @Nullable String keyword, ST
|
||||
}
|
||||
throw new IllegalStateException("Cannot build dialog context: " + String.join(", ", errors) + ".");
|
||||
} else {
|
||||
return new DialogContext(ksService, keyword, sttService, ttsService, voice, hliServices, audioSource,
|
||||
return new DialogContext(dtService, keyword, sttService, ttsService, voice, hliServices, audioSource,
|
||||
audioSink, locale, dialogGroup, locationItem, listeningItem, listeningMelody);
|
||||
}
|
||||
}
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.audio.AudioStream;
|
||||
|
||||
/**
|
||||
* This is the interface that an edge keyword spotting service has to implement.
|
||||
* Used to register a keyword spotting service that is running on a remote device.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface KSEdgeService extends KSService {
|
||||
|
||||
/**
|
||||
* This method links the remote keyword spotting process to a consumer.
|
||||
*
|
||||
* The method is supposed to return fast.
|
||||
*
|
||||
* @param ksListener Non-null {@link KSListener} that {@link KSEvent} events target
|
||||
* @throws KSException if any parameter is invalid or a problem occurs
|
||||
*/
|
||||
KSServiceHandle spot(KSListener ksListener) throws KSException;
|
||||
|
||||
@Override
|
||||
default KSServiceHandle spot(KSListener ksListener, AudioStream audioStream, Locale locale, String keyword)
|
||||
throws KSException {
|
||||
throw new KSException("An edge keyword spotter is not meant to process audio in the server");
|
||||
}
|
||||
}
|
||||
+2
-16
@@ -20,27 +20,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
* @author Kelly Davis - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class KSErrorEvent implements KSEvent {
|
||||
/**
|
||||
* The message describing the error
|
||||
*/
|
||||
private final String message;
|
||||
|
||||
public class KSErrorEvent extends DTErrorEvent implements KSEvent {
|
||||
/**
|
||||
* Constructs an instance with the passed {@code message}.
|
||||
*
|
||||
* @param message The message describing the error
|
||||
*/
|
||||
public KSErrorEvent(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the message describing this error
|
||||
*
|
||||
* @return The message describing this error
|
||||
*/
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
* @author Kelly Davis - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface KSEvent {
|
||||
public interface KSEvent extends DTEvent {
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
* @author Kelly Davis - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class KSException extends Exception {
|
||||
public class KSException extends DTException {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -25,7 +25,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
* @author Kelly Davis - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface KSListener {
|
||||
public interface KSListener extends DTListener {
|
||||
/**
|
||||
* Invoked when a {@link KSEvent} event occurs during keyword spotting.
|
||||
*
|
||||
|
||||
@@ -16,7 +16,6 @@ import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.audio.AudioFormat;
|
||||
import org.openhab.core.audio.AudioStream;
|
||||
|
||||
@@ -27,23 +26,7 @@ import org.openhab.core.audio.AudioStream;
|
||||
* @author Kai Kreuzer - Refactored to use AudioStream
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface KSService {
|
||||
|
||||
/**
|
||||
* Returns a simple string that uniquely identifies this service
|
||||
*
|
||||
* @return an id that identifies this service
|
||||
*/
|
||||
String getId();
|
||||
|
||||
/**
|
||||
* Returns a localized human readable label that can be used within UIs.
|
||||
*
|
||||
* @param locale the locale to provide the label for
|
||||
* @return a localized string to be used in UIs
|
||||
*/
|
||||
String getLabel(@Nullable Locale locale);
|
||||
|
||||
public interface KSService extends DTService {
|
||||
/**
|
||||
* Obtain the Locales available from this KSService
|
||||
*
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
* @author Kelly Davis - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface KSServiceHandle {
|
||||
public interface KSServiceHandle extends DTServiceHandle {
|
||||
/**
|
||||
* Aborts keyword spotting in the associated {@link KSService}
|
||||
*/
|
||||
|
||||
+1
-1
@@ -21,5 +21,5 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
* @author Yannick Schaus - Removed AudioSource information
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class KSpottedEvent implements KSEvent {
|
||||
public class KSpottedEvent extends DTTriggeredEvent implements KSEvent {
|
||||
}
|
||||
|
||||
+3
-1
@@ -173,8 +173,10 @@ public interface VoiceManager {
|
||||
* @param context with the configured services and options for the dialog
|
||||
* @throws IllegalStateException if required services are not compatible or the provided locale is not supported
|
||||
* by all these services or a dialog is already started for this audio source
|
||||
* @return a {@link DTServiceHandle} implementation if the dialog was started correctly.
|
||||
*/
|
||||
void startDialog(DialogContext context) throws IllegalStateException;
|
||||
@Nullable
|
||||
DTServiceHandle startDialog(DialogContext context) throws IllegalStateException;
|
||||
|
||||
/**
|
||||
* Stop the dialog associated to an audio source
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2025 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.core.voice.internal;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.audio.AudioDialogProvider;
|
||||
import org.openhab.core.audio.AudioSink;
|
||||
import org.openhab.core.audio.AudioSource;
|
||||
import org.openhab.core.voice.BasicDTService;
|
||||
import org.openhab.core.voice.DTListener;
|
||||
import org.openhab.core.voice.DTService;
|
||||
import org.openhab.core.voice.DTServiceHandle;
|
||||
import org.openhab.core.voice.DTTriggeredEvent;
|
||||
import org.openhab.core.voice.VoiceManager;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Allows the audio bundle to register a dialog that can be triggered programmatically.
|
||||
*
|
||||
* @author Miguel Álvarez - Initial contribution
|
||||
*/
|
||||
@Component(service = AudioDialogProvider.class)
|
||||
@NonNullByDefault
|
||||
public class AudioDialogProviderImpl implements AudioDialogProvider {
|
||||
private final Logger logger = LoggerFactory.getLogger(AudioDialogProviderImpl.class);
|
||||
private final VoiceManager voiceManager;
|
||||
|
||||
@Activate
|
||||
public AudioDialogProviderImpl(@Reference VoiceManager voiceManager) {
|
||||
this.voiceManager = voiceManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Runnable startDialog(AudioSink audioSink, AudioSource audioSource, @Nullable String locationItem,
|
||||
@Nullable String listeningItem, @Nullable Runnable onAbort) {
|
||||
DTService dt = new TriggerService(onAbort);
|
||||
TriggerServiceHandle triggerHandle = (TriggerServiceHandle) voiceManager.startDialog( //
|
||||
voiceManager.getDialogContextBuilder() //
|
||||
.withSource(audioSource) //
|
||||
.withSink(audioSink) //
|
||||
.withLocationItem(locationItem) //
|
||||
.withListeningItem(listeningItem) //
|
||||
.withDT(dt) //
|
||||
.build() //
|
||||
);
|
||||
if (triggerHandle == null) {
|
||||
return null;
|
||||
}
|
||||
return triggerHandle::trigger;
|
||||
}
|
||||
|
||||
private static class TriggerService implements BasicDTService {
|
||||
@Nullable
|
||||
Runnable onAbort;
|
||||
|
||||
public TriggerService(@Nullable Runnable onAbort) {
|
||||
this.onAbort = onAbort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DTServiceHandle registerListener(DTListener dtListener) {
|
||||
return new TriggerServiceHandle(dtListener, onAbort);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "audiodialog::anonymous::trigger";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel(@Nullable Locale locale) {
|
||||
// never shown
|
||||
return "Anonymous";
|
||||
}
|
||||
}
|
||||
|
||||
private static class TriggerServiceHandle implements DTServiceHandle {
|
||||
public final DTListener dtListener;
|
||||
public @Nullable Runnable abortCallback;
|
||||
|
||||
public TriggerServiceHandle(DTListener dtListener, @Nullable Runnable abortCallback) {
|
||||
this.dtListener = dtListener;
|
||||
this.abortCallback = abortCallback;
|
||||
}
|
||||
|
||||
public void trigger() {
|
||||
dtListener.dtEventReceived(new DTTriggeredEvent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void abort() {
|
||||
if (this.abortCallback != null) {
|
||||
this.abortCallback.run();
|
||||
this.abortCallback = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-26
@@ -33,14 +33,19 @@ import org.openhab.core.i18n.TranslationProvider;
|
||||
import org.openhab.core.items.ItemUtil;
|
||||
import org.openhab.core.items.events.ItemEventFactory;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.voice.BasicDTService;
|
||||
import org.openhab.core.voice.DTErrorEvent;
|
||||
import org.openhab.core.voice.DTEvent;
|
||||
import org.openhab.core.voice.DTException;
|
||||
import org.openhab.core.voice.DTService;
|
||||
import org.openhab.core.voice.DTServiceHandle;
|
||||
import org.openhab.core.voice.DTTriggeredEvent;
|
||||
import org.openhab.core.voice.DialogContext;
|
||||
import org.openhab.core.voice.KSEdgeService;
|
||||
import org.openhab.core.voice.KSErrorEvent;
|
||||
import org.openhab.core.voice.KSEvent;
|
||||
import org.openhab.core.voice.KSException;
|
||||
import org.openhab.core.voice.KSListener;
|
||||
import org.openhab.core.voice.KSService;
|
||||
import org.openhab.core.voice.KSServiceHandle;
|
||||
import org.openhab.core.voice.KSpottedEvent;
|
||||
import org.openhab.core.voice.RecognitionStartEvent;
|
||||
import org.openhab.core.voice.RecognitionStopEvent;
|
||||
@@ -98,7 +103,7 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
*/
|
||||
private boolean isSTTServerAborting = false;
|
||||
|
||||
private @Nullable KSServiceHandle ksServiceHandle;
|
||||
private @Nullable DTServiceHandle dtServiceHandle;
|
||||
private @Nullable STTServiceHandle sttServiceHandle;
|
||||
|
||||
private @Nullable AudioStream streamKS;
|
||||
@@ -113,8 +118,8 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
this.i18nProvider = i18nProvider;
|
||||
this.activeDialogGroups = activeDialogGroups;
|
||||
this.bundle = bundle;
|
||||
var ks = context.ks();
|
||||
this.ksFormat = ks != null
|
||||
var dt = context.dt();
|
||||
this.ksFormat = dt instanceof KSService ks
|
||||
? VoiceManagerImpl.getBestMatch(context.source().getSupportedFormats(), ks.getSupportedFormats())
|
||||
: null;
|
||||
this.sttFormat = VoiceManagerImpl.getBestMatch(context.source().getSupportedFormats(),
|
||||
@@ -151,34 +156,42 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
* Starts a persistent dialog
|
||||
*
|
||||
* @throws IllegalStateException if keyword spot service is misconfigured
|
||||
* @return {@link DTServiceHandle} or null if dialog fails to start
|
||||
*/
|
||||
public void start() throws IllegalStateException {
|
||||
KSService ksService = dialogContext.ks();
|
||||
public @Nullable DTServiceHandle start() throws IllegalStateException {
|
||||
DTService dtService = dialogContext.dt();
|
||||
String keyword = dialogContext.keyword();
|
||||
if (ksService != null && keyword != null) {
|
||||
if (dtService != null && keyword != null) {
|
||||
abortKS();
|
||||
closeStreamKS();
|
||||
AudioFormat fmt = ksFormat;
|
||||
if (fmt == null) {
|
||||
logger.warn("No compatible audio format found for ks '{}' and source '{}'", ksService.getId(),
|
||||
dialogContext.source().getId());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (ksService instanceof KSEdgeService service) {
|
||||
service.spot(this);
|
||||
} else {
|
||||
if (dtService instanceof KSService ksService) {
|
||||
AudioFormat fmt = ksFormat;
|
||||
if (fmt == null) {
|
||||
logger.warn("No compatible audio format found for ks '{}' and source '{}'", ksService.getId(),
|
||||
dialogContext.source().getId());
|
||||
return null;
|
||||
}
|
||||
AudioStream stream = dialogContext.source().getInputStream(fmt);
|
||||
streamKS = stream;
|
||||
ksServiceHandle = ksService.spot(this, stream, dialogContext.locale(), keyword);
|
||||
dtServiceHandle = ksService.spot(this, stream, dialogContext.locale(), keyword);
|
||||
} else if (dtService instanceof BasicDTService basicDTService) {
|
||||
dtServiceHandle = basicDTService.registerListener(this);
|
||||
} else {
|
||||
logger.warn("Voice manager is not able to handle this DTService implementation '{}'",
|
||||
dtService.getClass().getName());
|
||||
}
|
||||
playStartSound();
|
||||
return dtServiceHandle;
|
||||
} catch (AudioException e) {
|
||||
logger.warn("Encountered audio error: {}", e.getMessage());
|
||||
} catch (KSException e) {
|
||||
logger.warn("Encountered error calling spot: {}", e.getMessage());
|
||||
closeStreamKS();
|
||||
} catch (DTException e) {
|
||||
logger.warn("Encountered error starting the dialog trigger: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
throw new IllegalStateException("Unable to run persistent dialog ks service is not configured");
|
||||
}
|
||||
@@ -258,10 +271,10 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
}
|
||||
|
||||
private void abortKS() {
|
||||
KSServiceHandle handle = ksServiceHandle;
|
||||
DTServiceHandle handle = dtServiceHandle;
|
||||
if (handle != null) {
|
||||
handle.abort();
|
||||
ksServiceHandle = null;
|
||||
dtServiceHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,22 +327,28 @@ public class DialogProcessor implements KSListener, STTListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ksEventReceived(KSEvent ksEvent) {
|
||||
public void dtEventReceived(DTEvent dtEvent) {
|
||||
isSTTServerAborting = false;
|
||||
if (ksEvent instanceof KSpottedEvent) {
|
||||
logger.debug("KSpottedEvent event received");
|
||||
if (dtEvent instanceof DTTriggeredEvent) {
|
||||
logger.debug("{} event received",
|
||||
(dtEvent instanceof KSpottedEvent) ? "KSpottedEvent" : "DTTriggeredEvent");
|
||||
try {
|
||||
startSimpleDialog();
|
||||
} catch (IllegalStateException e) {
|
||||
logger.warn("{}", e.getMessage());
|
||||
}
|
||||
} else if (ksEvent instanceof KSErrorEvent kse) {
|
||||
logger.debug("KSErrorEvent event received");
|
||||
} else if (dtEvent instanceof DTErrorEvent dte) {
|
||||
logger.debug("{} event received", dte instanceof KSErrorEvent ? "KSErrorEvent" : "DTErrorEvent");
|
||||
String text = i18nProvider.getText(bundle, "error.ks-error", null, dialogContext.locale());
|
||||
say(text == null ? kse.getMessage() : text.replace("{0}", kse.getMessage()));
|
||||
say(text == null ? dte.getMessage() : text.replace("{0}", dte.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ksEventReceived(KSEvent ksEvent) {
|
||||
dtEventReceived(ksEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void sttEventReceived(STTEvent sttEvent) {
|
||||
if (sttEvent instanceof SpeechRecognitionEvent sre) {
|
||||
|
||||
+1
-1
@@ -406,7 +406,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
|
||||
Collection<DialogContext> dialogContexts = voiceManager.getDialogsContexts();
|
||||
if (!dialogContexts.isEmpty()) {
|
||||
dialogContexts.stream().sorted(comparing(s -> s.source().getId())).forEach(c -> {
|
||||
var ks = c.ks();
|
||||
var ks = c.dt();
|
||||
String ksText = ks != null ? String.format(", KS: %s, Keyword: %s", ks.getId(), c.keyword()) : "";
|
||||
String locationText = c.locationItem() != null ? String.format(" Location: %s", c.locationItem()) : "";
|
||||
console.println(String.format(
|
||||
|
||||
+13
-7
@@ -57,6 +57,7 @@ import org.openhab.core.i18n.TranslationProvider;
|
||||
import org.openhab.core.library.types.PercentType;
|
||||
import org.openhab.core.storage.Storage;
|
||||
import org.openhab.core.storage.StorageService;
|
||||
import org.openhab.core.voice.DTServiceHandle;
|
||||
import org.openhab.core.voice.DialogContext;
|
||||
import org.openhab.core.voice.DialogRegistration;
|
||||
import org.openhab.core.voice.KSService;
|
||||
@@ -608,10 +609,14 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startDialog(DialogContext context) throws IllegalStateException {
|
||||
var ksService = context.ks();
|
||||
public @Nullable DTServiceHandle startDialog(DialogContext context) throws IllegalStateException {
|
||||
var dtService = context.dt();
|
||||
var ksKeyword = context.keyword();
|
||||
if (ksService == null || ksKeyword == null) {
|
||||
if (dtService == null) {
|
||||
throw new IllegalStateException(
|
||||
"Invalid dialog context for persistent dialog: missing dialog trigger implementation");
|
||||
}
|
||||
if (dtService instanceof KSService && (ksKeyword == null || ksKeyword.isEmpty())) {
|
||||
throw new IllegalStateException(
|
||||
"Invalid dialog context for persistent dialog: missing keyword spot configuration");
|
||||
}
|
||||
@@ -619,7 +624,8 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
if (b == null) {
|
||||
throw new IllegalStateException("Bundle is not (yet?) set.");
|
||||
}
|
||||
if (!checkLocales(ksService.getSupportedLocales(), context.locale())
|
||||
if ((dtService instanceof KSService ksService
|
||||
&& !checkLocales(ksService.getSupportedLocales(), context.locale()))
|
||||
|| !checkLocales(context.stt().getSupportedLocales(), context.locale()) || !context.hlis().stream()
|
||||
.allMatch(interpreter -> checkLocales(interpreter.getSupportedLocales(), context.locale()))) {
|
||||
throw new IllegalStateException("Cannot start dialog as provided locale is not supported by all services.");
|
||||
@@ -631,7 +637,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
processor = new DialogProcessor(context, this, this.eventPublisher, this.activeDialogGroups,
|
||||
this.i18nProvider, b);
|
||||
dialogProcessors.put(context.source().getId(), processor);
|
||||
processor.start();
|
||||
return processor.start();
|
||||
} else {
|
||||
throw new IllegalStateException(
|
||||
String.format("Cannot start dialog as a dialog is already started for audio source '%s'.",
|
||||
@@ -785,8 +791,8 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
|
||||
protected void removeKSService(KSService ksService) {
|
||||
this.ksServices.remove(ksService.getId());
|
||||
stopDialogs(dialog -> {
|
||||
var ks = dialog.dialogContext.ks();
|
||||
return ks != null && ks.getId().equals(ksService.getId());
|
||||
var dt = dialog.dialogContext.dt();
|
||||
return dt != null && dt.getId().equals(ksService.getId());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<module>org.openhab.core.io.transport.serial.rxtx.rfc2217</module>
|
||||
<module>org.openhab.core.io.transport.upnp</module>
|
||||
<module>org.openhab.core.io.websocket</module>
|
||||
<module>org.openhab.core.io.websocket.audio</module>
|
||||
<module>org.openhab.core.io.jetty.certificate</module>
|
||||
<module>org.openhab.core.model.core</module>
|
||||
<module>org.openhab.core.model.item</module>
|
||||
|
||||
@@ -239,6 +239,12 @@
|
||||
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.websocket/${project.version}</bundle>
|
||||
</feature>
|
||||
|
||||
<feature name="openhab-core-io-websocket-audio" version="${project.version}">
|
||||
<feature>openhab-core-base</feature>
|
||||
<feature dependency="true">openhab-core-io-websocket</feature>
|
||||
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.websocket.audio/${project.version}</bundle>
|
||||
</feature>
|
||||
|
||||
<feature name="openhab-core-io-transport-coap" version="${project.version}">
|
||||
<feature>openhab-core-base</feature>
|
||||
|
||||
@@ -466,6 +472,7 @@
|
||||
<feature>openhab-core-io-rest-transform</feature>
|
||||
<feature>openhab-core-io-rest-voice</feature>
|
||||
<feature>openhab-core-io-websocket</feature>
|
||||
<feature>openhab-core-io-websocket-audio</feature>
|
||||
<feature>openhab-core-model-lsp</feature>
|
||||
<feature>openhab-core-model-item</feature>
|
||||
<feature>openhab-core-model-persistence</feature>
|
||||
|
||||
Reference in New Issue
Block a user