diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index 8862acbdead..0f118c6079b 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -320,7 +320,7 @@ org.apache.aries.spifly org.apache.aries.spifly.dynamic.bundle - 1.3.4 + 1.3.6 diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java index b07f57086dd..95f4b79f9e4 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java @@ -20,6 +20,7 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -29,6 +30,8 @@ import org.openhab.core.OpenHAB; import org.openhab.core.addon.Addon; import org.openhab.core.addon.AddonEventFactory; +import org.openhab.core.addon.AddonInfo; +import org.openhab.core.addon.AddonInfoRegistry; import org.openhab.core.addon.AddonService; import org.openhab.core.addon.AddonType; import org.openhab.core.cache.ExpiringCache; @@ -66,13 +69,15 @@ public abstract class AbstractRemoteAddonService implements AddonService { protected final ConfigurationAdmin configurationAdmin; protected final ExpiringCache> cachedRemoteAddons = new ExpiringCache<>(Duration.ofMinutes(15), this::getRemoteAddons); + protected final AddonInfoRegistry addonInfoRegistry; protected List cachedAddons = List.of(); protected List installedAddons = List.of(); private final Logger logger = LoggerFactory.getLogger(AbstractRemoteAddonService.class); public AbstractRemoteAddonService(EventPublisher eventPublisher, ConfigurationAdmin configurationAdmin, - StorageService storageService, String servicePid) { + StorageService storageService, AddonInfoRegistry addonInfoRegistry, String servicePid) { + this.addonInfoRegistry = addonInfoRegistry; this.eventPublisher = eventPublisher; this.configurationAdmin = configurationAdmin; this.installedAddonStorage = storageService.getStorage(servicePid); @@ -83,6 +88,16 @@ protected BundleVersion getCoreVersion() { return new BundleVersion(FrameworkUtil.getBundle(OpenHAB.class).getVersion().toString()); } + private Addon convertFromStorage(Map.Entry entry) { + Addon storedAddon = Objects.requireNonNull(gson.fromJson(entry.getValue(), Addon.class)); + AddonInfo addonInfo = addonInfoRegistry.getAddonInfo(storedAddon.getType() + "-" + storedAddon.getId()); + if (addonInfo != null && storedAddon.getConfigDescriptionURI().isBlank()) { + return Addon.create(storedAddon).withConfigDescriptionURI(addonInfo.getConfigDescriptionURI()).build(); + } else { + return storedAddon; + } + } + @Override public void refreshSource() { if (!addonHandlers.stream().allMatch(MarketplaceAddonHandler::isReady)) { @@ -92,8 +107,7 @@ public void refreshSource() { } List addons = new ArrayList<>(); try { - installedAddonStorage.stream().map(e -> Objects.requireNonNull(gson.fromJson(e.getValue(), Addon.class))) - .forEach(addons::add); + installedAddonStorage.stream().map(this::convertFromStorage).forEach(addons::add); } catch (JsonSyntaxException e) { List.copyOf(installedAddonStorage.getKeys()).forEach(installedAddonStorage::remove); logger.error( diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java index 7054b6f94bd..69104bec7e0 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java @@ -36,6 +36,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.addon.Addon; +import org.openhab.core.addon.AddonInfoRegistry; import org.openhab.core.addon.AddonService; import org.openhab.core.addon.AddonType; import org.openhab.core.addon.marketplace.AbstractRemoteAddonService; @@ -115,8 +116,8 @@ public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService @Activate public CommunityMarketplaceAddonService(final @Reference EventPublisher eventPublisher, @Reference ConfigurationAdmin configurationAdmin, @Reference StorageService storageService, - Map config) { - super(eventPublisher, configurationAdmin, storageService, SERVICE_PID); + @Reference AddonInfoRegistry addonInfoRegistry, Map config) { + super(eventPublisher, configurationAdmin, storageService, addonInfoRegistry, SERVICE_PID); modified(config); } @@ -200,10 +201,13 @@ protected List getRemoteAddons() { @Override public @Nullable Addon getAddon(String uid, @Nullable Locale locale) { + String queryId = uid.startsWith(ADDON_ID_PREFIX) ? uid : ADDON_ID_PREFIX + uid; + // check if it is an installed add-on (cachedAddons also contains possibly incomplete results from the remote // side, we need to retrieve them from Discourse) - if (installedAddons.contains(uid)) { - return cachedAddons.stream().filter(e -> uid.equals(e.getUid())).findAny().orElse(null); + + if (installedAddons.contains(queryId)) { + return cachedAddons.stream().filter(e -> queryId.equals(e.getUid())).findAny().orElse(null); } if (!remoteEnabled()) { @@ -437,11 +441,13 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) { boolean installed = addonHandlers.stream() .anyMatch(handler -> handler.supports(type, contentType) && handler.isInstalled(uid)); - return Addon.create(uid).withType(type).withId(id).withContentType(contentType).withLabel(topic.title) - .withImageLink(topic.imageUrl).withLink(COMMUNITY_TOPIC_URL + topic.id.toString()) + Addon.Builder builder = Addon.create(uid).withType(type).withId(id).withContentType(contentType) + .withLabel(topic.title).withImageLink(topic.imageUrl) + .withLink(COMMUNITY_TOPIC_URL + topic.id.toString()) .withAuthor(topic.postStream.posts[0].displayUsername).withMaturity(maturity) - .withDetailedDescription(detailedDescription).withInstalled(installed).withProperties(properties) - .build(); + .withDetailedDescription(detailedDescription).withInstalled(installed).withProperties(properties); + + return builder.build(); } private @Nullable String determineIdFromUrl(String url) { diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java index 626c6852724..0b39fd1460c 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java @@ -30,6 +30,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.addon.Addon; +import org.openhab.core.addon.AddonInfoRegistry; import org.openhab.core.addon.AddonService; import org.openhab.core.addon.marketplace.AbstractRemoteAddonService; import org.openhab.core.addon.marketplace.MarketplaceAddonHandler; @@ -78,8 +79,9 @@ public class JsonAddonService extends AbstractRemoteAddonService { @Activate public JsonAddonService(@Reference EventPublisher eventPublisher, @Reference StorageService storageService, - @Reference ConfigurationAdmin configurationAdmin, Map config) { - super(eventPublisher, configurationAdmin, storageService, SERVICE_PID); + @Reference ConfigurationAdmin configurationAdmin, @Reference AddonInfoRegistry addonInfoRegistry, + Map config) { + super(eventPublisher, configurationAdmin, storageService, addonInfoRegistry, SERVICE_PID); modified(config); } diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonServiceTest.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonServiceTest.java index 58004557856..dac9fa3fc09 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonServiceTest.java +++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonServiceTest.java @@ -43,6 +43,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.openhab.core.addon.Addon; +import org.openhab.core.addon.AddonInfoRegistry; import org.openhab.core.addon.marketplace.test.TestAddonHandler; import org.openhab.core.addon.marketplace.test.TestAddonService; import org.openhab.core.events.Event; @@ -64,6 +65,7 @@ @NonNullByDefault public class AbstractRemoteAddonServiceTest { private @Mock @NonNullByDefault({}) StorageService storageService; + private @Mock @NonNullByDefault({}) AddonInfoRegistry addonInfoRegistry; private @Mock @NonNullByDefault({}) ConfigurationAdmin configurationAdmin; private @Mock @NonNullByDefault({}) EventPublisher eventPublisher; private @Mock @NonNullByDefault({}) Configuration configuration; @@ -82,7 +84,7 @@ public void initialize() throws IOException { addonHandler = new TestAddonHandler(); - addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService); + addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService, addonInfoRegistry); addonService.addAddonHandler(addonHandler); } @@ -93,7 +95,7 @@ public void testSourceNotRefreshedIfAddonHandlerNotReady() { addonHandler = new TestAddonHandler(); addonHandler.setReady(false); - addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService); + addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService, addonInfoRegistry); addonService.addAddonHandler(addonHandler); List addons = addonService.getAddons(null); diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/test/TestAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/test/TestAddonService.java index 73ffa5cead0..fe5bc672a5a 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/test/TestAddonService.java +++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/marketplace/test/TestAddonService.java @@ -21,6 +21,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.addon.Addon; +import org.openhab.core.addon.AddonInfoRegistry; import org.openhab.core.addon.marketplace.AbstractRemoteAddonService; import org.openhab.core.addon.marketplace.BundleVersion; import org.openhab.core.addon.marketplace.MarketplaceAddonHandler; @@ -51,8 +52,8 @@ public class TestAddonService extends AbstractRemoteAddonService { private int remoteCalls = 0; public TestAddonService(EventPublisher eventPublisher, ConfigurationAdmin configurationAdmin, - StorageService storageService) { - super(eventPublisher, configurationAdmin, storageService, SERVICE_PID); + StorageService storageService, AddonInfoRegistry addonInfoRegistry) { + super(eventPublisher, configurationAdmin, storageService, addonInfoRegistry, SERVICE_PID); } @Override diff --git a/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/Addon.java b/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/Addon.java index 84019c79fb9..e1d06175ae9 100644 --- a/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/Addon.java +++ b/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/Addon.java @@ -304,6 +304,33 @@ public static Builder create(String uid) { return new Builder(uid); } + public static Builder create(Addon addon) { + Addon.Builder builder = new Builder(addon.uid); + builder.id = addon.id; + builder.label = addon.label; + builder.version = addon.version; + builder.maturity = addon.maturity; + builder.compatible = addon.compatible; + builder.contentType = addon.contentType; + builder.link = addon.link; + builder.author = addon.author; + builder.verifiedAuthor = addon.verifiedAuthor; + builder.installed = addon.installed; + builder.type = addon.type; + builder.description = addon.description; + builder.detailedDescription = addon.detailedDescription; + builder.configDescriptionURI = addon.configDescriptionURI; + builder.keywords = addon.keywords; + builder.countries = addon.countries; + builder.license = addon.license; + builder.connection = addon.connection; + builder.backgroundColor = addon.backgroundColor; + builder.imageLink = addon.imageLink; + builder.properties = addon.properties; + builder.loggerPackages = addon.loggerPackages; + return builder; + } + public static class Builder { private final String uid; private String id; diff --git a/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/internal/JarFileAddonService.java b/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/internal/JarFileAddonService.java index 2b7819900b6..8b8fe68bcbe 100644 --- a/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/internal/JarFileAddonService.java +++ b/bundles/org.openhab.core.addon/src/main/java/org/openhab/core/addon/internal/JarFileAddonService.java @@ -53,6 +53,7 @@ public class JarFileAddonService extends BundleTracker implements AddonService { public static final String SERVICE_ID = "jar"; public static final String SERVICE_NAME = "JAR-File add-on service"; + private static final String ADDONS_CONTENT_TYPE = "application/vnd.openhab.bundle"; private static final Map ADDON_TYPE_MAP = Map.of( // "automation", new AddonType("automation", "Automation"), // @@ -155,7 +156,7 @@ private Addon toAddon(Bundle bundle, AddonInfo addonInfo) { .withVersion(bundle.getVersion().toString()).withLabel(addonInfo.getName()) .withConfigDescriptionURI(addonInfo.getConfigDescriptionURI()) .withDescription(Objects.requireNonNullElse(addonInfo.getDescription(), bundle.getSymbolicName())) - .build(); + .withContentType(ADDONS_CONTENT_TYPE).build(); } @Override @@ -168,7 +169,8 @@ public List getAddons(@Nullable Locale locale) { @Override public @Nullable Addon getAddon(String id, @Nullable Locale locale) { - return addons.get(id); + String queryId = id.startsWith(ADDON_ID_PREFIX) ? id : ADDON_ID_PREFIX + id; + return addons.get(queryId); } @Override diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioFormat.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioFormat.java index 2a6942b6b97..0eeb7ab2e2b 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioFormat.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioFormat.java @@ -484,7 +484,7 @@ public String toString() { + (bigEndian != null ? "bigEndian=" + bigEndian + ", " : "") + (bitDepth != null ? "bitDepth=" + bitDepth + ", " : "") + (bitRate != null ? "bitRate=" + bitRate + ", " : "") - + (frequency != null ? "frequency=" + frequency : "") + (channels != null ? "channels=" + channels : "") - + "]"; + + (frequency != null ? "frequency=" + frequency + ", " : "") + + (channels != null ? "channels=" + channels : "") + "]"; } } diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioHTTPServer.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioHTTPServer.java index 2cb85ee5833..d74b88ef989 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioHTTPServer.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/AudioHTTPServer.java @@ -31,7 +31,7 @@ public interface AudioHTTPServer { /** * Creates a relative url for a given {@link AudioStream} where it can be requested a single time. * Note that the HTTP header only contains "Content-length", if the passed stream is an instance of - * {@link FixedLengthAudioStream}. + * {@link SizeableAudioStream}. * If the client that requests the url expects this header field to be present, make sure to pass such an instance. * Streams are closed after having been served. * diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ByteArrayAudioStream.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ByteArrayAudioStream.java index 6dfcae37ff2..6386e39db53 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ByteArrayAudioStream.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ByteArrayAudioStream.java @@ -19,7 +19,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** - * This is an implementation of a {@link FixedLengthAudioStream}, which is based on a simple byte array. + * This is an implementation of an {@link AudioStream} with known length and a clone method, which is based on a simple + * byte array. * * @author Kai Kreuzer - Initial contribution */ @@ -60,4 +61,19 @@ public long length() { public InputStream getClonedStream() { return new ByteArrayAudioStream(bytes, format); } + + @Override + public synchronized void mark(int readlimit) { + stream.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + stream.reset(); + } + + @Override + public boolean markSupported() { + return true; + } } diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ClonableAudioStream.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ClonableAudioStream.java index 111e1c1d2d9..57dfcdc3c80 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ClonableAudioStream.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/ClonableAudioStream.java @@ -17,12 +17,12 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** - * This is an {@link AudioStream}, that can be cloned + * This is for an {@link AudioStream}, that can be cloned * - * @author Gwendal Roulleau - Initial contribution, separation from FixedLengthAudioStream + * @author Gwendal Roulleau - Initial contribution, separation from {@link FixedLengthAudioStream} */ @NonNullByDefault -public abstract class ClonableAudioStream extends AudioStream { +public interface ClonableAudioStream { /** * Returns a new, fully independent stream instance, which can be read and closed without impacting the original @@ -31,5 +31,5 @@ public abstract class ClonableAudioStream extends AudioStream { * @return a new input stream that can be consumed by the caller * @throws AudioException if stream cannot be created */ - public abstract InputStream getClonedStream() throws AudioException; + public InputStream getClonedStream() throws AudioException; } diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FileAudioStream.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FileAudioStream.java index 7247742aaa6..ab35257b6f5 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FileAudioStream.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FileAudioStream.java @@ -42,9 +42,11 @@ public class FileAudioStream extends FixedLengthAudioStream implements Disposabl private final File file; private final AudioFormat audioFormat; - private InputStream inputStream; + private FileInputStream inputStream; private final long length; private final boolean isTemporaryFile; + private int markedOffset = 0; + private int alreadyRead = 0; public FileAudioStream(File file) throws AudioException { this(file, getAudioFormat(file)); @@ -87,7 +89,7 @@ private static AudioFormat parseWavFormat(File file) throws AudioException { } } - private static InputStream getInputStream(File file) throws AudioException { + private static FileInputStream getInputStream(File file) throws AudioException { try { return new FileInputStream(file); } catch (FileNotFoundException e) { @@ -102,7 +104,9 @@ public AudioFormat getFormat() { @Override public int read() throws IOException { - return inputStream.read(); + int read = inputStream.read(); + alreadyRead++; + return read; } @Override @@ -124,11 +128,23 @@ public synchronized void reset() throws IOException { } try { inputStream = getInputStream(file); + inputStream.skipNBytes(markedOffset); + alreadyRead = markedOffset; } catch (AudioException e) { throw new IOException("Cannot reset file input stream: " + e.getMessage(), e); } } + @Override + public synchronized void mark(int readlimit) { + markedOffset = alreadyRead; + } + + @Override + public boolean markSupported() { + return true; + } + @Override public InputStream getClonedStream() throws AudioException { return getInputStream(file); diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FixedLengthAudioStream.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FixedLengthAudioStream.java index 4a737d50f99..daaf6657a33 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FixedLengthAudioStream.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/FixedLengthAudioStream.java @@ -15,18 +15,15 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** - * This is a {@link ClonableAudioStream}, which can also provide information about its absolute length. + * This is a {@link AudioStream}, which can also provide information about its absolute length and get cloned. * * @author Kai Kreuzer - Initial contribution - * @author Gwendal Roulleau - Separate getClonedStream into its own class + * @author Gwendal Roulleau - Separate getClonedStream and length into their own interface. + * @deprecated You should consider using {@link ClonableAudioStream} and/or {@link SizeableAudioStream} to detect audio + * stream capabilities */ @NonNullByDefault -public abstract class FixedLengthAudioStream extends ClonableAudioStream { +@Deprecated +public abstract class FixedLengthAudioStream extends AudioStream implements SizeableAudioStream, ClonableAudioStream { - /** - * Provides the length of the stream in bytes. - * - * @return absolute length in bytes - */ - public abstract long length(); } diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/SizeableAudioStream.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/SizeableAudioStream.java new file mode 100644 index 00000000000..6074f2424c2 --- /dev/null +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/SizeableAudioStream.java @@ -0,0 +1,31 @@ +/** + * 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.core.audio; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This is for an {@link AudioStream}, which size is known + * + * @author Gwendal Roulleau - Initial contribution, separation from {@link FixedLengthAudioStream} + */ +@NonNullByDefault +public interface SizeableAudioStream { + + /** + * Provides the length of the stream in bytes. + * + * @return absolute length in bytes + */ + public long length(); +} diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/URLAudioStream.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/URLAudioStream.java index e43ab639760..d365829097a 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/URLAudioStream.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/URLAudioStream.java @@ -40,7 +40,7 @@ * @author Christoph Weitkamp - Refactored use of filename extension */ @NonNullByDefault -public class URLAudioStream extends ClonableAudioStream { +public class URLAudioStream extends AudioStream implements ClonableAudioStream { private static final Pattern PLS_STREAM_PATTERN = Pattern.compile("^File[0-9]=(.+)$"); diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioServlet.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioServlet.java index e7acbb867ee..3a5ac9377ab 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioServlet.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/internal/AudioServlet.java @@ -48,7 +48,7 @@ import org.openhab.core.audio.ByteArrayAudioStream; import org.openhab.core.audio.ClonableAudioStream; import org.openhab.core.audio.FileAudioStream; -import org.openhab.core.audio.FixedLengthAudioStream; +import org.openhab.core.audio.SizeableAudioStream; import org.openhab.core.audio.StreamServed; import org.openhab.core.audio.utils.AudioSinkUtils; import org.openhab.core.common.ThreadPoolManager; @@ -135,8 +135,8 @@ private InputStream prepareInputStream(final StreamServed streamServed, final Ht } // try to set the content-length, if possible - if (streamServed.audioStream() instanceof FixedLengthAudioStream fixedLengthServedStream) { - final long size = fixedLengthServedStream.length(); + if (streamServed.audioStream() instanceof SizeableAudioStream sizeableServedStream) { + final long size = sizeableServedStream.length(); resp.setContentLength((int) size); } @@ -285,9 +285,9 @@ public StreamServed serve(AudioStream originalStream, int seconds, boolean multi return streamToServe; } - private ClonableAudioStream createClonableInputStream(AudioStream stream, String streamId) throws IOException { + private AudioStream createClonableInputStream(AudioStream stream, String streamId) throws IOException { byte[] dataBytes = stream.readNBytes(ONETIME_STREAM_BUFFER_MAX_SIZE + 1); - ClonableAudioStream clonableAudioStreamResult; + AudioStream clonableAudioStreamResult; if (dataBytes.length <= ONETIME_STREAM_BUFFER_MAX_SIZE) { // we will use an in memory buffer to avoid disk operation clonableAudioStreamResult = new ByteArrayAudioStream(dataBytes, stream.getFormat()); diff --git a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/utils/AudioSinkUtilsImpl.java b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/utils/AudioSinkUtilsImpl.java index f49da0ce8e0..069096baee8 100644 --- a/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/utils/AudioSinkUtilsImpl.java +++ b/bundles/org.openhab.core.audio/src/main/java/org/openhab/core/audio/utils/AudioSinkUtilsImpl.java @@ -66,11 +66,20 @@ public class AudioSinkUtilsImpl implements AudioSinkUtils { .longValue(); return startTime + computedDuration; } catch (IOException | UnsupportedAudioFileException e) { - logger.debug("Cannot compute the duration of input stream", e); + logger.debug("Cannot compute the duration of input stream with method java stream sound analysis", + e); + Integer bitRate = audioFormat.getBitRate(); + if (bitRate != null && bitRate != 0) { + long computedDuration = Float.valueOf((1f * dataTransferedLength / bitRate) * 1000000000) + .longValue(); + return startTime + computedDuration; + } else { + logger.debug("Cannot compute the duration of input stream by using audio format information"); + } return null; } } else if (AudioFormat.CODEC_MP3.equals(audioFormat.getCodec())) { - // not precise, no VBR, but better than nothing + // not accurate, no VBR support, but better than nothing Bitstream bitstream = new Bitstream(new ByteArrayInputStream(dataBytes)); try { Header h = bitstream.readFrame(); diff --git a/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/AudioServletTest.java b/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/AudioServletTest.java index 6f7200604a6..da5eafc1d0e 100644 --- a/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/AudioServletTest.java +++ b/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/AudioServletTest.java @@ -29,7 +29,6 @@ import org.openhab.core.audio.AudioStream; import org.openhab.core.audio.ByteArrayAudioStream; import org.openhab.core.audio.FileAudioStream; -import org.openhab.core.audio.FixedLengthAudioStream; import org.openhab.core.audio.StreamServed; import org.openhab.core.audio.internal.utils.BundledSoundFileHandler; @@ -214,7 +213,7 @@ public void oneTimeStreamIsClosedAndRemovedAfterServed() throws Exception { @Test public void multiTimeStreamIsClosedAfterExpired() throws Exception { AtomicInteger cloneCounter = new AtomicInteger(); - FixedLengthAudioStream audioStream = mock(FixedLengthAudioStream.class); + ByteArrayAudioStream audioStream = mock(ByteArrayAudioStream.class); AudioStream clonedStream = mock(AudioStream.class); AudioFormat audioFormat = mock(AudioFormat.class); when(audioStream.getFormat()).thenReturn(audioFormat); @@ -250,7 +249,7 @@ public void multiTimeStreamIsClosedAfterExpired() throws Exception { @Test public void streamsAreClosedOnDeactivate() throws Exception { AudioStream oneTimeStream = mock(AudioStream.class); - FixedLengthAudioStream multiTimeStream = mock(FixedLengthAudioStream.class); + ByteArrayAudioStream multiTimeStream = mock(ByteArrayAudioStream.class); serveStream(oneTimeStream); serveStream(multiTimeStream, 10); diff --git a/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java b/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java index 89d189c12b8..6bf2a61d40b 100644 --- a/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java +++ b/bundles/org.openhab.core.audio/src/test/java/org/openhab/core/audio/internal/fake/AudioSinkFake.java @@ -21,7 +21,7 @@ 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.ByteArrayAudioStream; import org.openhab.core.audio.URLAudioStream; import org.openhab.core.audio.UnsupportedAudioFormatException; import org.openhab.core.audio.UnsupportedAudioStreamException; @@ -49,8 +49,8 @@ public class AudioSinkFake implements AudioSink { public boolean isUnsupportedAudioStreamExceptionExpected; private static final Set SUPPORTED_AUDIO_FORMATS = Set.of(AudioFormat.MP3, AudioFormat.WAV); - private static final Set> SUPPORTED_AUDIO_STREAMS = Set - .of(FixedLengthAudioStream.class, URLAudioStream.class); + private static final Set> SUPPORTED_AUDIO_STREAMS = Set.of(ByteArrayAudioStream.class, + URLAudioStream.class); @Override public String getId() { diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryBundleTracker.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryBundleTracker.java index 3b4f6f4dd9f..69c23fecc66 100644 --- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryBundleTracker.java +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryBundleTracker.java @@ -50,22 +50,19 @@ public class ScriptEngineFactoryBundleTracker extends BundleTracker impl private final Logger logger = LoggerFactory.getLogger(ScriptEngineFactoryBundleTracker.class); private final ReadyService readyService; - private final StartLevelService startLevelService; private final Map bundles = new ConcurrentHashMap<>(); + private boolean startLevel = false; private boolean ready = false; @Activate - public ScriptEngineFactoryBundleTracker(final @Reference ReadyService readyService, - final @Reference StartLevelService startLevelService, BundleContext bc) { + public ScriptEngineFactoryBundleTracker(final @Reference ReadyService readyService, BundleContext bc) { super(bc, STATE_MASK, null); this.readyService = readyService; - this.startLevelService = startLevelService; - this.open(); readyService.registerTracker(this, new ReadyMarkerFilter().withType(StartLevelService.STARTLEVEL_MARKER_TYPE) - .withIdentifier(Integer.toString(StartLevelService.STARTLEVEL_OSGI))); + .withIdentifier(Integer.toString(StartLevelService.STARTLEVEL_MODEL))); } @Deactivate @@ -85,8 +82,8 @@ public Bundle addingBundle(@NonNullByDefault({}) Bundle bundle, @Nullable Bundle if (isScriptingBundle(bundle)) { logger.debug("Added {}: {} ", bsn, stateToString(state)); bundles.put(bsn, state); + checkReady(); } - checkReady(); return bundle; } @@ -99,8 +96,8 @@ public void modifiedBundle(@NonNullByDefault({}) Bundle bundle, @Nullable Bundle if (isScriptingBundle(bundle)) { logger.debug("Modified {}: {}", bsn, stateToString(state)); bundles.put(bsn, state); + checkReady(); } - checkReady(); } @Override @@ -110,27 +107,35 @@ public void removedBundle(@NonNullByDefault({}) Bundle bundle, @Nullable BundleE if (isScriptingBundle(bundle)) { logger.debug("Removed {}", bsn); bundles.remove(bsn); + checkReady(); } - checkReady(); } @Override public void onReadyMarkerAdded(ReadyMarker readyMarker) { + logger.debug("Readymarker {} added", readyMarker); + startLevel = true; checkReady(); } @Override public void onReadyMarkerRemoved(ReadyMarker readyMarker) { + logger.debug("Readymarker {} removed", readyMarker); + startLevel = false; ready = false; readyService.unmarkReady(READY_MARKER); } - private void checkReady() { - if (!ready && startLevelService.getStartLevel() > StartLevelService.STARTLEVEL_OSGI && allBundlesActive()) { - logger.info("All automation bundles ready."); + private synchronized void checkReady() { + boolean allBundlesActive = allBundlesActive(); + logger.trace("ready: {}, startlevel: {}, allActive: {}", ready, startLevel, allBundlesActive); + + if (!ready && startLevel && allBundlesActive) { + logger.debug("Adding ready marker: All automation bundles ready ({})", bundles); readyService.markReady(READY_MARKER); ready = true; - } else if (ready && !allBundlesActive()) { + } else if (ready && !allBundlesActive) { + logger.debug("Removing ready marker: Not all automation bundles ready ({})", bundles); readyService.unmarkReady(READY_MARKER); ready = false; } diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java index 1585d0be863..9d3adc6e14c 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleEngineImpl.java @@ -266,7 +266,7 @@ public RuleEngineImpl(final @Reference ModuleTypeRegistry moduleTypeRegistry, this.ruleRegistry = ruleRegistry; this.readyService = readyService; - listener = new RegistryChangeListener() { + listener = new RegistryChangeListener<>() { @Override public void added(Rule rule) { RuleEngineImpl.this.addRule(rule); @@ -337,8 +337,7 @@ public void added(ModuleType moduleType) { synchronized (this) { Set rulesPerModule = mapModuleTypeToRules.get(moduleTypeName); if (rulesPerModule != null) { - rules = new HashSet<>(); - rules.addAll(rulesPerModule); + rules = new HashSet<>(rulesPerModule); } } if (rules != null) { @@ -366,8 +365,7 @@ public void updated(ModuleType oldElement, ModuleType moduleType) { synchronized (this) { Set rulesPerModule = mapModuleTypeToRules.get(moduleTypeName); if (rulesPerModule != null) { - rules = new HashSet<>(); - rules.addAll(rulesPerModule); + rules = new HashSet<>(rulesPerModule); } } if (rules != null) { @@ -402,8 +400,7 @@ protected void addModuleHandlerFactory(ModuleHandlerFactory moduleHandlerFactory moduleHandlerFactories.put(moduleTypeName, moduleHandlerFactory); Set rulesPerModule = mapModuleTypeToRules.get(moduleTypeName); if (rulesPerModule != null) { - rules = new HashSet<>(); - rules.addAll(rulesPerModule); + rules = new HashSet<>(rulesPerModule); } } if (rules != null) { @@ -505,10 +502,8 @@ private void setRule(WrappedRule rule) { final boolean activated = activateRule(rule); if (activated) { Future f = scheduleTasks.remove(rUID); - if (f != null) { - if (!f.isDone()) { - f.cancel(true); - } + if ((f != null) && !f.isDone()) { + f.cancel(true); } } } @@ -942,7 +937,7 @@ private void removeMissingModuleTypes(Collection moduleTypes) { for (Entry> e : mapMissingHandlers.entrySet()) { String rUID = e.getKey(); List missingTypes = e.getValue(); - StringBuffer sb = new StringBuffer(); + StringBuilder sb = new StringBuilder(); sb.append("Missing handlers: "); for (String typeUID : missingTypes) { sb.append(typeUID).append(", "); @@ -1097,7 +1092,7 @@ private Map getContext(String ruleUID, @Nullable Set throw new IllegalStateException("context cannot be null at that point - please report a bug."); } if (connections != null) { - StringBuffer sb = new StringBuffer(); + StringBuilder sb = new StringBuilder(); for (Connection c : connections) { String outputModuleId = c.getOutputModuleId(); if (outputModuleId != null) { @@ -1438,7 +1433,7 @@ public void onReadyMarkerRemoved(ReadyMarker readyMarker) { private void executeRulesWithStartLevel() { getScheduledExecutor().submit(() -> { ruleRegistry.getAll().stream() // - .filter(r -> mustTrigger(r)) // + .filter(this::mustTrigger) // .forEach(r -> runNow(r.getUID(), true, Map.of(SystemTriggerHandler.OUT_STARTLEVEL, StartLevelService.STARTLEVEL_RULES))); started = true; diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleRegistryImpl.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleRegistryImpl.java index c7c5971823a..f50a9f5d081 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleRegistryImpl.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/RuleRegistryImpl.java @@ -500,7 +500,7 @@ private void validateConfiguration(List configDescri if (isOptionalConfig(configDescriptions)) { return; } else { - StringBuffer statusDescription = new StringBuffer(); + StringBuilder statusDescription = new StringBuilder(); String msg = " '%s';"; for (ConfigDescriptionParameter configParameter : configDescriptions) { if (configParameter.isRequired()) { @@ -517,7 +517,7 @@ private void validateConfiguration(List configDescri processValue(configurations.remove(configParameterName), configParameter); } if (!configurations.isEmpty()) { - StringBuffer statusDescription = new StringBuffer(); + StringBuilder statusDescription = new StringBuilder(); String msg = " '%s';"; for (String name : configurations.keySet()) { statusDescription.append(String.format(msg, name)); @@ -610,7 +610,7 @@ private boolean checkType(Type type, Object configValue) { */ private void resolveModuleConfigReferences(List modules, Map ruleConfiguration) { if (modules != null) { - StringBuffer statusDescription = new StringBuffer(); + StringBuilder statusDescription = new StringBuilder(); for (Module module : modules) { try { ReferenceResolver.updateConfiguration(module.getConfiguration(), ruleConfiguration, logger); diff --git a/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/ConsoleInterpreter.java b/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/ConsoleInterpreter.java index d4f94db622a..01c8d076eb4 100644 --- a/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/ConsoleInterpreter.java +++ b/bundles/org.openhab.core.io.console/src/main/java/org/openhab/core/io/console/ConsoleInterpreter.java @@ -33,7 +33,7 @@ public class ConsoleInterpreter { public static String getHelp(final String base, final String separator, Collection extensions) { final List usages = ConsoleInterpreter.getUsages(extensions); - final StringBuffer buffer = new StringBuffer(); + final StringBuilder buffer = new StringBuilder(); buffer.append("---openHAB commands---\n\t"); for (int i = 0; i < usages.size(); i++) { diff --git a/bundles/org.openhab.core.io.monitor/src/main/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetric.java b/bundles/org.openhab.core.io.monitor/src/main/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetric.java index b74cd99416a..4501bbacfed 100644 --- a/bundles/org.openhab.core.io.monitor/src/main/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetric.java +++ b/bundles/org.openhab.core.io.monitor/src/main/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetric.java @@ -40,16 +40,18 @@ import io.micrometer.core.instrument.Tags; /** - * The {@link ThingStateMetric} class implements a metric for the openHAB things states. + * The {@link ThingStateMetric} class implements a metric for the openHAB things + * states. * * @author Robert Bach - Initial contribution + * @author Scott Hraban - Create Meter using thingUid instead of thingId during + * bind phase */ @NonNullByDefault public class ThingStateMetric implements OpenhabCoreMeterBinder, EventSubscriber { private final Logger logger = LoggerFactory.getLogger(ThingStateMetric.class); public static final String METRIC_NAME = "openhab.thing.state"; private static final String THING_TAG_NAME = "thing"; - private static final String THINGSTATUS_TOPIC_PREFIX = "openhab/things/"; private final ThingRegistry thingRegistry; private final Meter.Id commonMeterId; private final Map registeredMeters = new HashMap<>(); @@ -70,7 +72,7 @@ public void bindTo(@NonNullByDefault({}) MeterRegistry meterRegistry) { logger.debug("ThingStateMetric is being bound..."); this.meterRegistry = meterRegistry; thingRegistry.getAll().forEach( - thing -> createOrUpdateMetricForBundleState(thing.getUID().getId(), thing.getStatus().ordinal())); + thing -> createOrUpdateMetricForBundleState(thing.getUID().getAsString(), thing.getStatus().ordinal())); eventSubscriberRegistration = this.bundleContext.registerService(EventSubscriber.class.getName(), this, null); } @@ -111,10 +113,13 @@ public Set getSubscribedEventTypes() { @Override public void receive(Event event) { - logger.trace("Received ThingStatusInfo(Changed)Event..."); - String thingId = event.getTopic().substring(THINGSTATUS_TOPIC_PREFIX.length(), - event.getTopic().lastIndexOf('/')); - ThingStatus status = gson.fromJson(event.getPayload(), ThingStatusInfo.class).getStatus(); - createOrUpdateMetricForBundleState(thingId, status.ordinal()); + if (event instanceof ThingStatusInfoEvent thingEvent) { + logger.trace("Received ThingStatusInfo(Changed)Event..."); + String thingUid = thingEvent.getThingUID().getAsString(); + ThingStatus status = gson.fromJson(event.getPayload(), ThingStatusInfo.class).getStatus(); + createOrUpdateMetricForBundleState(thingUid, status.ordinal()); + } else { + logger.trace("Received unsubscribed for event type {}", event.getClass().getSimpleName()); + } } } diff --git a/bundles/org.openhab.core.io.monitor/src/test/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetricTest.java b/bundles/org.openhab.core.io.monitor/src/test/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetricTest.java new file mode 100644 index 00000000000..61118050546 --- /dev/null +++ b/bundles/org.openhab.core.io.monitor/src/test/java/org/openhab/core/io/monitor/internal/metrics/ThingStateMetricTest.java @@ -0,0 +1,91 @@ +/** + * 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.core.io.monitor.internal.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.events.ThingEventFactory; +import org.openhab.core.thing.internal.ThingImpl; +import org.osgi.framework.BundleContext; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +/** + * Tests for ThingStateMetric class + * + * @author Scott Hraban - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@NonNullByDefault +public class ThingStateMetricTest { + + @Test + public void testThingUidAlwaysUsedToCreateMeter() { + final String strThingTypeUid = "sonos:Amp"; + + final String strThingUid = strThingTypeUid + ":RINCON_347E5C0D150501400"; + ThingUID thingUid = new ThingUID(strThingUid); + Thing thing = new ThingImpl(new ThingTypeUID(strThingTypeUid), thingUid); + + final String strThingUid2 = strThingTypeUid + ":foo"; + ThingUID thingUid2 = new ThingUID(strThingUid2); + + ThingRegistry thingRegistry = mock(ThingRegistry.class); + + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + + ThingStateMetric thingStateMetric = new ThingStateMetric(mock(BundleContext.class), thingRegistry, + new HashSet()); + + // Only one meter registered at bind time + doReturn(Collections.singleton(thing)).when(thingRegistry).getAll(); + thingStateMetric.bindTo(meterRegistry); + + List meters = meterRegistry.getMeters(); + assertEquals(1, meters.size()); + assertEquals(strThingUid, meters.get(0).getId().getTag("thing")); + + // Still only one meter registered after receiving an event + ThingStatusInfo thingStatusInfo = new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null); + thingStateMetric.receive(ThingEventFactory.createStatusInfoEvent(thingUid, thingStatusInfo)); + + meters = meterRegistry.getMeters(); + assertEquals(1, meters.size()); + assertEquals(strThingUid, meters.get(0).getId().getTag("thing")); + + // Now another one is added + thingStateMetric.receive(ThingEventFactory.createStatusInfoEvent(thingUid2, thingStatusInfo)); + + meters = meterRegistry.getMeters(); + assertEquals(2, meters.size()); + } +} diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedThingDTO.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedThingDTO.java index dfa37e601a3..c17ce4df1b4 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedThingDTO.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedThingDTO.java @@ -31,7 +31,7 @@ public class EnrichedThingDTO extends AbstractThingDTO { public List channels; public ThingStatusInfo statusInfo; - public final FirmwareStatusDTO firmwareStatus; + public FirmwareStatusDTO firmwareStatus; public boolean editable; /** diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/SitemapSubscriptionService.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/SitemapSubscriptionService.java index 70bafa9e4bf..d788c1762ea 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/SitemapSubscriptionService.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/SitemapSubscriptionService.java @@ -42,6 +42,8 @@ import org.openhab.core.model.sitemap.sitemap.Widget; import org.openhab.core.thing.events.ChannelDescriptionChangedEvent; import org.openhab.core.ui.items.ItemUIRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; @@ -72,6 +74,7 @@ public class SitemapSubscriptionService implements ModelRepositoryChangeListener private static final int DEFAULT_MAX_SUBSCRIPTIONS = 50; private final Logger logger = LoggerFactory.getLogger(SitemapSubscriptionService.class); + private final BundleContext bundleContext; public interface SitemapSubscriptionCallback { @@ -94,15 +97,17 @@ public interface SitemapSubscriptionCallback { private final Map creationInstants = new ConcurrentHashMap<>(); /* sitemap+page -> listener */ - private final Map pageChangeListeners = new ConcurrentHashMap<>(); + private final Map pageChangeListeners = new ConcurrentHashMap<>(); /* Max number of subscriptions at the same time */ private int maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS; @Activate - public SitemapSubscriptionService(Map config, final @Reference ItemUIRegistry itemUIRegistry) { - applyConfig(config); + public SitemapSubscriptionService(Map config, final @Reference ItemUIRegistry itemUIRegistry, + BundleContext bundleContext) { this.itemUIRegistry = itemUIRegistry; + this.bundleContext = bundleContext; + applyConfig(config); } @Deactivate @@ -110,9 +115,7 @@ protected void deactivate() { pageOfSubscription.clear(); callbacks.clear(); creationInstants.clear(); - for (PageChangeListener listener : pageChangeListeners.values()) { - listener.dispose(); - } + pageChangeListeners.values().forEach(l -> l.serviceRegistration.unregister()); pageChangeListeners.clear(); } @@ -150,7 +153,7 @@ protected void removeSitemapProvider(SitemapProvider provider) { * Creates a new subscription with the given id. * * @param callback an instance that should receive the events - * @returns a unique id that identifies the subscription or null if the limit of subscriptions is already reached + * @return a unique id that identifies the subscription or null if the limit of subscriptions is already reached */ public @Nullable String createSubscription(SitemapSubscriptionCallback callback) { if (maxSubscriptions >= 0 && callbacks.size() >= maxSubscriptions) { @@ -176,9 +179,9 @@ public void removeSubscription(String subscriptionId) { String sitemapPage = pageOfSubscription.remove(subscriptionId); if (sitemapPage != null && !pageOfSubscription.values().contains(sitemapPage)) { // this was the only subscription listening on this page, so we can dispose the listener - PageChangeListener listener = pageChangeListeners.remove(sitemapPage); + ListenerRecord listener = pageChangeListeners.remove(sitemapPage); if (listener != null) { - listener.dispose(); + listener.serviceRegistration().unregister(); } } logger.debug("Removed subscription with id {} ({} active subscriptions)", subscriptionId, callbacks.size()); @@ -249,13 +252,14 @@ public void setPageId(String subscriptionId, String sitemapName, String pageId) } private void addCallbackToListener(String sitemapName, String pageId, SitemapSubscriptionCallback callback) { - PageChangeListener listener = pageChangeListeners.get(getValue(sitemapName, pageId)); - if (listener == null) { - // there is no listener for this page yet, so let's try to create one - listener = new PageChangeListener(sitemapName, pageId, itemUIRegistry, collectWidgets(sitemapName, pageId)); - pageChangeListeners.put(getValue(sitemapName, pageId), listener); - } - listener.addCallback(callback); + ListenerRecord listener = pageChangeListeners.computeIfAbsent(getValue(sitemapName, pageId), v -> { + PageChangeListener newListener = new PageChangeListener(sitemapName, pageId, itemUIRegistry, + collectWidgets(sitemapName, pageId)); + ServiceRegistration registration = bundleContext.registerService(EventSubscriber.class.getName(), + newListener, null); + return new ListenerRecord(newListener, registration); + }); + listener.pageChangeListener().addCallback(callback); } private EList collectWidgets(String sitemapName, String pageId) { @@ -278,12 +282,12 @@ private EList collectWidgets(String sitemapName, String pageId) { } private void removeCallbackFromListener(String sitemapPage, SitemapSubscriptionCallback callback) { - PageChangeListener oldListener = pageChangeListeners.get(sitemapPage); + ListenerRecord oldListener = pageChangeListeners.get(sitemapPage); if (oldListener != null) { - oldListener.removeCallback(callback); - if (!pageOfSubscription.values().contains(sitemapPage)) { + oldListener.pageChangeListener().removeCallback(callback); + if (!pageOfSubscription.containsValue(sitemapPage)) { // no other callbacks are left here, so we can safely dispose the listener - oldListener.dispose(); + oldListener.serviceRegistration().unregister(); pageChangeListeners.remove(sitemapPage); } } @@ -311,14 +315,14 @@ public void modelChanged(String modelName, EventType type) { String changedSitemapName = modelName.substring(0, modelName.length() - SITEMAP_SUFFIX.length()); - for (Entry listenerEntry : pageChangeListeners.entrySet()) { + for (Entry listenerEntry : pageChangeListeners.entrySet()) { String sitemapWithPage = listenerEntry.getKey(); String sitemapName = extractSitemapName(sitemapWithPage); String pageId = extractPageId(sitemapWithPage); if (sitemapName.equals(changedSitemapName)) { EList widgets = collectWidgets(sitemapName, pageId); - listenerEntry.getValue().sitemapContentChanged(widgets); + listenerEntry.getValue().pageChangeListener().sitemapContentChanged(widgets); } } } @@ -336,9 +340,7 @@ public void checkAliveClients() { } } // Send an ALIVE event to all subscribers to trigger an exception for dead subscribers - for (Entry listenerEntry : pageChangeListeners.entrySet()) { - listenerEntry.getValue().sendAliveEvent(); - } + pageChangeListeners.values().forEach(l -> l.pageChangeListener().sendAliveEvent()); } @Override @@ -355,19 +357,22 @@ public void receive(Event event) { // members and predictions aren't really possible in that case (or at least would be highly complex). return; } - for (PageChangeListener pageChangeListener : pageChangeListeners.values()) { + for (ListenerRecord listener : pageChangeListeners.values()) { if (prediction.isConfirmation()) { - pageChangeListener.keepCurrentState(item); + listener.pageChangeListener().keepCurrentState(item); } else { - pageChangeListener.changeStateTo(item, prediction.getPredictedState()); + listener.pageChangeListener().changeStateTo(item, prediction.getPredictedState()); } } } else if (event instanceof ChannelDescriptionChangedEvent channelDescriptionChangedEvent) { channelDescriptionChangedEvent.getLinkedItemNames().forEach(itemName -> { - for (PageChangeListener pageChangeListener : pageChangeListeners.values()) { - pageChangeListener.descriptionChanged(itemName); + for (ListenerRecord listener : pageChangeListeners.values()) { + listener.pageChangeListener().descriptionChanged(itemName); } }); } } + + private record ListenerRecord(PageChangeListener pageChangeListener, ServiceRegistration serviceRegistration) { + } } diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/PageChangeListener.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/PageChangeListener.java index 3252af2f6bd..30aa6bc5e92 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/PageChangeListener.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/PageChangeListener.java @@ -20,16 +20,19 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.eclipse.emf.common.util.EList; import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventSubscriber; import org.openhab.core.io.rest.core.item.EnrichedItemDTOMapper; import org.openhab.core.io.rest.sitemap.SitemapSubscriptionService.SitemapSubscriptionCallback; -import org.openhab.core.items.GenericItem; -import org.openhab.core.items.GroupItem; import org.openhab.core.items.Item; import org.openhab.core.items.ItemNotFoundException; -import org.openhab.core.items.StateChangeListener; +import org.openhab.core.items.events.GroupStateUpdatedEvent; +import org.openhab.core.items.events.ItemEvent; +import org.openhab.core.items.events.ItemStateChangedEvent; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.model.sitemap.sitemap.Chart; import org.openhab.core.model.sitemap.sitemap.ColorArray; @@ -45,7 +48,7 @@ * @author Kai Kreuzer - Initial contribution * @author Laurent Garnier - Added support for icon color */ -public class PageChangeListener implements StateChangeListener { +public class PageChangeListener implements EventSubscriber { private static final int REVERT_INTERVAL = 300; private final ScheduledExecutorService scheduler = ThreadPoolManager @@ -55,6 +58,7 @@ public class PageChangeListener implements StateChangeListener { private final ItemUIRegistry itemUIRegistry; private EList widgets; private Set items; + private final HashSet filterItems = new HashSet<>(); private final List callbacks = Collections.synchronizedList(new ArrayList<>()); private Set distinctCallbacks = Collections.emptySet(); @@ -75,23 +79,10 @@ public PageChangeListener(String sitemapName, String pageId, ItemUIRegistry item } private void updateItemsAndWidgets(EList widgets) { - if (this.widgets != null) { - // cleanup statechange listeners in case widgets were removed - items = getAllItems(this.widgets); - for (Item item : items) { - if (item instanceof GenericItem genericItem) { - genericItem.removeStateChangeListener(this); - } - } - } - this.widgets = widgets; items = getAllItems(widgets); - for (Item item : items) { - if (item instanceof GenericItem genericItem) { - genericItem.addStateChangeListener(this); - } - } + filterItems.clear(); + filterItems.addAll(items.stream().map(Item::getName).collect(Collectors.toSet())); } public String getSitemapName() { @@ -113,19 +104,6 @@ public void removeCallback(SitemapSubscriptionCallback callback) { distinctCallbacks = new HashSet<>(callbacks); } - /** - * Disposes this instance and releases all resources. - */ - public void dispose() { - for (Item item : items) { - if (item instanceof GenericItem genericItem) { - genericItem.removeStateChangeListener(this); - } else if (item instanceof GroupItem groupItem) { - groupItem.removeStateChangeListener(this); - } - } - } - /** * Collects all items that are represented by a given list of widgets * @@ -182,26 +160,6 @@ private void constructAndSendEvents(Item item, State newState) { } } - @Override - public void stateChanged(Item item, State oldState, State newState) { - // For all items except group, send an event only when the event state is changed. - if (item instanceof GroupItem) { - return; - } - constructAndSendEvents(item, newState); - } - - @Override - public void stateUpdated(Item item, State state) { - // For group item only, send an event each time the event state is updated. - // It allows updating the group label while the group state is unchanged, - // for example the count in label for Group:Switch:OR - if (!(item instanceof GroupItem)) { - return; - } - constructAndSendEvents(item, state); - } - public void keepCurrentState(Item item) { scheduler.schedule(() -> { constructAndSendEvents(item, item.getState()); @@ -338,4 +296,24 @@ private Set constructSitemapEventsForUpdatedDescr(Item item, List< } return events; } + + @Override + public Set getSubscribedEventTypes() { + return Set.of(ItemStateChangedEvent.TYPE, GroupStateUpdatedEvent.TYPE); + } + + @Override + public void receive(Event event) { + if (event instanceof ItemEvent itemEvent && filterItems.contains(itemEvent.getItemName())) { + Item item = itemUIRegistry.get(itemEvent.getItemName()); + if (item == null) { + return; + } + if (event instanceof GroupStateUpdatedEvent groupStateUpdatedEvent) { + constructAndSendEvents(item, groupStateUpdatedEvent.getItemState()); + } else if (event instanceof ItemStateChangedEvent itemStateChangedEvent) { + constructAndSendEvents(item, itemStateChangedEvent.getItemState()); + } + } + } } diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java index 478f244a141..2c92feed0f7 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/SitemapResource.java @@ -21,10 +21,12 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; +import java.util.stream.Collectors; import javax.annotation.security.RolesAllowed; import javax.servlet.http.HttpServletRequest; @@ -56,6 +58,8 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.Role; import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventSubscriber; import org.openhab.core.io.rest.JSONResponse; import org.openhab.core.io.rest.LocaleService; import org.openhab.core.io.rest.RESTConstants; @@ -67,7 +71,8 @@ import org.openhab.core.items.GenericItem; import org.openhab.core.items.Item; import org.openhab.core.items.ItemNotFoundException; -import org.openhab.core.items.StateChangeListener; +import org.openhab.core.items.events.ItemEvent; +import org.openhab.core.items.events.ItemStateChangedEvent; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.HSBType; import org.openhab.core.model.sitemap.SitemapProvider; @@ -125,8 +130,9 @@ * @author Markus Rathgeb - Migrated to JAX-RS Whiteboard Specification * @author Wouter Born - Migrated to OpenAPI annotations * @author Laurent Garnier - Added support for icon color + * @author Mark Herwege - Added pattern and unit fields */ -@Component(service = RESTResource.class) +@Component(service = { RESTResource.class, EventSubscriber.class }) @JaxrsResource @JaxrsName(SitemapResource.PATH_SITEMAPS) @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + RESTConstants.JAX_RS_NAME + ")") @@ -136,7 +142,7 @@ @Tag(name = SitemapResource.PATH_SITEMAPS) @NonNullByDefault public class SitemapResource - implements RESTResource, SitemapSubscriptionCallback, SseBroadcaster.Listener { + implements RESTResource, SitemapSubscriptionCallback, SseBroadcaster.Listener, EventSubscriber { private final Logger logger = LoggerFactory.getLogger(SitemapResource.class); @@ -176,6 +182,7 @@ public class SitemapResource .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON); private @Nullable ScheduledFuture cleanSubscriptionsJob; + private Set stateChangeListeners = new CopyOnWriteArraySet<>(); @Activate public SitemapResource( // @@ -520,6 +527,8 @@ private PageDTO createPageBean(String sitemapName, @Nullable String title, @Null bean.valuecolor = convertItemValueColor(itemUIRegistry.getValueColor(widget), itemState); bean.iconcolor = convertItemValueColor(itemUIRegistry.getIconColor(widget), itemState); bean.label = itemUIRegistry.getLabel(widget); + bean.pattern = itemUIRegistry.getFormatPattern(widget); + bean.unit = itemUIRegistry.getUnitForWidget(widget); bean.type = widget.eClass().getName(); bean.visibility = itemUIRegistry.getVisiblity(widget); if (widget instanceof LinkableWidget linkableWidget) { @@ -683,13 +692,11 @@ private boolean blockUnlessChangeOccurs(String sitemapname, String pageId) { private boolean waitForChanges(EList widgets) { long startTime = (new Date()).getTime(); boolean timeout = false; - BlockingStateChangeListener listener = new BlockingStateChangeListener(); - // let's get all items for these widgets - Set items = getAllItems(widgets); - for (GenericItem item : items) { - item.addStateChangeListener(listener); - } - while (!listener.hasChangeOccurred() && !timeout) { + Set items = getAllItems(widgets).stream().map(Item::getName).collect(Collectors.toSet()); + BlockingStateChangeListener listener = new BlockingStateChangeListener(items); + stateChangeListeners.add(listener); + + while (!listener.hasChanged() && !timeout) { timeout = (new Date()).getTime() - startTime > TIMEOUT_IN_MS; try { Thread.sleep(300); @@ -698,9 +705,8 @@ private boolean waitForChanges(EList widgets) { break; } } - for (GenericItem item : items) { - item.removeStateChangeListener(listener); - } + + stateChangeListeners.remove(listener); return timeout; } @@ -779,34 +785,16 @@ private Set getItemsInColorCond(EList colorList) { return items; } - /** - * This is a state change listener, which is merely used to determine, if a - * state change has occurred on one of a list of items. - * - * @author Kai Kreuzer - Initial contribution - * - */ - private static class BlockingStateChangeListener implements StateChangeListener { - - private boolean changed = false; - - @Override - public void stateChanged(Item item, State oldState, State newState) { - changed = true; - } - - /** - * determines, whether a state change has occurred since its creation - * - * @return true, if a state has changed - */ - public boolean hasChangeOccurred() { - return changed; - } + @Override + public Set getSubscribedEventTypes() { + return Set.of(ItemStateChangedEvent.TYPE); + } - @Override - public void stateUpdated(Item item, State state) { - // ignore if the state did not change + @Override + public void receive(Event event) { + if (event instanceof ItemEvent itemEvent) { + String itemName = itemEvent.getItemName(); + stateChangeListeners.forEach(l -> l.itemChanged(itemName)); } } @@ -852,4 +840,23 @@ public void sseEventSinkRemoved(SseEventSink sink, SseSinkInfo info) { subscriptions.removeSubscription(info.subscriptionId); knownSubscriptions.remove(info.subscriptionId); } + + private static class BlockingStateChangeListener { + private final Set items; + private boolean changed = false; + + public BlockingStateChangeListener(Set items) { + this.items = items; + } + + public void itemChanged(String item) { + if (items.contains(item)) { + changed = true; + } + } + + public boolean hasChanged() { + return changed; + } + } } diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java index db47ddd27e8..c9dc713b082 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/main/java/org/openhab/core/io/rest/sitemap/internal/WidgetDTO.java @@ -24,6 +24,7 @@ * @author Kai Kreuzer - Initial contribution * @author Chris Jackson - Initial contribution * @author Laurent Garnier - New field iconcolor + * @author Mark herwege - New fields pattern, unit */ public class WidgetDTO { @@ -38,6 +39,9 @@ public class WidgetDTO { public String valuecolor; public String iconcolor; + public String pattern; + public String unit; + // widget-specific attributes public final List mappings = new ArrayList<>(); public Boolean switchSupport; diff --git a/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java b/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java index fe384aeabb0..b8a0b0da6ab 100644 --- a/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java +++ b/bundles/org.openhab.core.io.rest.sitemap/src/test/java/org/openhab/core/io/rest/sitemap/internal/SitemapResourceTest.java @@ -18,7 +18,6 @@ import static org.hamcrest.collection.IsEmptyCollection.empty; import static org.mockito.Mockito.*; -import java.math.BigDecimal; import java.util.Collection; import java.util.List; import java.util.Locale; @@ -45,7 +44,7 @@ import org.openhab.core.io.rest.sitemap.SitemapSubscriptionService; import org.openhab.core.items.GenericItem; import org.openhab.core.items.ItemNotFoundException; -import org.openhab.core.library.types.DecimalType; +import org.openhab.core.items.events.ItemEvent; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PercentType; import org.openhab.core.model.sitemap.SitemapProvider; @@ -158,11 +157,13 @@ public void whenSitemapsAreProvidedShouldReturnSitemapBeans() { @Test public void whenLongPollingShouldObserveItems() { + ItemEvent itemEvent = mock(ItemEvent.class); + when(itemEvent.getItemName()).thenReturn(item.getName()); new Thread(() -> { try { Thread.sleep(STATE_UPDATE_WAIT_TIME); // wait for the #getPageData call and listeners to attach to the // item - item.setState(PercentType.ZERO); + sitemapResource.receive(itemEvent); } catch (InterruptedException e) { } }).start(); @@ -180,11 +181,13 @@ public void whenLongPollingShouldObserveItems() { @Test public void whenLongPollingShouldObserveItemsFromVisibilityRules() { + ItemEvent itemEvent = mock(ItemEvent.class); + when(itemEvent.getItemName()).thenReturn(visibilityRuleItem.getName()); new Thread(() -> { try { Thread.sleep(STATE_UPDATE_WAIT_TIME); // wait for the #getPageData call and listeners to attach to the // item - visibilityRuleItem.setState(new DecimalType(BigDecimal.ONE)); + sitemapResource.receive(itemEvent); } catch (InterruptedException e) { } }).start(); @@ -202,11 +205,13 @@ public void whenLongPollingShouldObserveItemsFromVisibilityRules() { @Test public void whenLongPollingShouldObserveItemsFromLabelColorConditions() { + ItemEvent itemEvent = mock(ItemEvent.class); + when(itemEvent.getItemName()).thenReturn(labelColorItem.getName()); new Thread(() -> { try { Thread.sleep(STATE_UPDATE_WAIT_TIME); // wait for the #getPageData call and listeners to attach to the // item - labelColorItem.setState(new DecimalType(BigDecimal.ONE)); + sitemapResource.receive(itemEvent); } catch (InterruptedException e) { } }).start(); @@ -224,11 +229,13 @@ public void whenLongPollingShouldObserveItemsFromLabelColorConditions() { @Test public void whenLongPollingShouldObserveItemsFromValueColorConditions() { + ItemEvent itemEvent = mock(ItemEvent.class); + when(itemEvent.getItemName()).thenReturn(valueColorItem.getName()); new Thread(() -> { try { Thread.sleep(STATE_UPDATE_WAIT_TIME); // wait for the #getPageData call and listeners to attach to the // item - valueColorItem.setState(new DecimalType(BigDecimal.ONE)); + sitemapResource.receive(itemEvent); } catch (InterruptedException e) { } }).start(); diff --git a/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/Persistence.xtext b/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/Persistence.xtext index 4e34511c2e1..7699c6e7405 100644 --- a/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/Persistence.xtext +++ b/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/Persistence.xtext @@ -27,17 +27,34 @@ Filter: ; FilterDetails: - ThresholdFilter | TimeFilter + ThresholdFilter | TimeFilter | EqualsFilter | NotEqualsFilter | IncludeFilter | NotIncludeFilter ; ThresholdFilter: - '>' value=DECIMAL unit=STRING + '>' (relative?='%')? value=DECIMAL unit=STRING? ; TimeFilter: - value=INT unit=('s' | 'm' | 'h' | 'd') + 'T' value=INT unit=('s' | 'm' | 'h' | 'd') ; +EqualsFilter: + '=' values+=STRING (',' values+=STRING)* +; + +NotEqualsFilter: + '!' values+=STRING (',' values+=STRING)* +; + +IncludeFilter: + '[]' lower=DECIMAL upper=DECIMAL unit=STRING? +; + +NotIncludeFilter: + '][' lower=DECIMAL upper=DECIMAL unit=STRING? +; + + PersistenceConfiguration: items+=(AllConfig | ItemConfig | GroupConfig) (',' items+=(AllConfig | ItemConfig | GroupConfig))* ('->' alias=STRING)? ((':' ('strategy' '=' strategies+=[Strategy|ID] (',' strategies+=[Strategy|ID])*)? diff --git a/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/internal/PersistenceModelManager.java b/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/internal/PersistenceModelManager.java index 3531eb4976c..9b5d2b24de6 100644 --- a/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/internal/PersistenceModelManager.java +++ b/bundles/org.openhab.core.model.persistence/src/org/openhab/core/model/persistence/internal/PersistenceModelManager.java @@ -26,9 +26,13 @@ import org.openhab.core.model.core.ModelRepositoryChangeListener; import org.openhab.core.model.persistence.persistence.AllConfig; import org.openhab.core.model.persistence.persistence.CronStrategy; +import org.openhab.core.model.persistence.persistence.EqualsFilter; import org.openhab.core.model.persistence.persistence.Filter; import org.openhab.core.model.persistence.persistence.GroupConfig; +import org.openhab.core.model.persistence.persistence.IncludeFilter; import org.openhab.core.model.persistence.persistence.ItemConfig; +import org.openhab.core.model.persistence.persistence.NotEqualsFilter; +import org.openhab.core.model.persistence.persistence.NotIncludeFilter; import org.openhab.core.model.persistence.persistence.PersistenceConfiguration; import org.openhab.core.model.persistence.persistence.PersistenceModel; import org.openhab.core.model.persistence.persistence.Strategy; @@ -40,7 +44,9 @@ import org.openhab.core.persistence.config.PersistenceConfig; import org.openhab.core.persistence.config.PersistenceGroupConfig; import org.openhab.core.persistence.config.PersistenceItemConfig; +import org.openhab.core.persistence.filter.PersistenceEqualsFilter; import org.openhab.core.persistence.filter.PersistenceFilter; +import org.openhab.core.persistence.filter.PersistenceIncludeFilter; import org.openhab.core.persistence.filter.PersistenceThresholdFilter; import org.openhab.core.persistence.filter.PersistenceTimeFilter; import org.openhab.core.persistence.registry.PersistenceServiceConfiguration; @@ -175,13 +181,21 @@ private List mapFilters(List filters) { } private PersistenceFilter mapFilter(Filter filter) { - if (filter.getDefinition() instanceof TimeFilter) { - TimeFilter timeFilter = (TimeFilter) filter.getDefinition(); + if (filter.getDefinition() instanceof TimeFilter timeFilter) { return new PersistenceTimeFilter(filter.getName(), timeFilter.getValue(), timeFilter.getUnit()); - } else if (filter.getDefinition() instanceof ThresholdFilter) { - ThresholdFilter thresholdFilter = (ThresholdFilter) filter.getDefinition(); + } else if (filter.getDefinition() instanceof ThresholdFilter thresholdFilter) { return new PersistenceThresholdFilter(filter.getName(), thresholdFilter.getValue(), - thresholdFilter.getUnit()); + thresholdFilter.getUnit(), thresholdFilter.isRelative()); + } else if (filter.getDefinition() instanceof EqualsFilter equalsFilter) { + return new PersistenceEqualsFilter(filter.getName(), equalsFilter.getValues(), false); + } else if (filter.getDefinition() instanceof NotEqualsFilter notEqualsFilter) { + return new PersistenceEqualsFilter(filter.getName(), notEqualsFilter.getValues(), true); + } else if (filter.getDefinition() instanceof IncludeFilter includeFilter) { + return new PersistenceIncludeFilter(filter.getName(), includeFilter.getLower(), includeFilter.getUpper(), + includeFilter.getUnit(), false); + } else if (filter.getDefinition() instanceof NotIncludeFilter notIncludeFilter) { + return new PersistenceIncludeFilter(filter.getName(), notIncludeFilter.getLower(), + notIncludeFilter.getUpper(), notIncludeFilter.getUnit(), true); } throw new IllegalArgumentException("Unknown filter type " + filter.getClass()); } diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceFilterDTO.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceFilterDTO.java index d59bc9a480d..097e92204d7 100644 --- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceFilterDTO.java +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceFilterDTO.java @@ -13,6 +13,7 @@ package org.openhab.core.persistence.dto; import java.math.BigDecimal; +import java.util.List; /** * The {@link org.openhab.core.persistence.dto.PersistenceFilterDTO} is used for transferring persistence filter @@ -21,7 +22,24 @@ * @author Jan N. Klug - Initial contribution */ public class PersistenceFilterDTO { - public String name = ""; - public BigDecimal value = BigDecimal.ZERO; - public String unit = ""; + public String name; + + // threshold and time + public BigDecimal value; + + // threshold + public Boolean relative; + + // threshold, include/exclude + public String unit; + + // include/exclude + public BigDecimal lower; + public BigDecimal upper; + + // equals/not equals + public List values; + + // equals/not equals, include/exclude + public Boolean inverted; } diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceServiceConfigurationDTO.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceServiceConfigurationDTO.java index bc796a36871..e4faa9ed20f 100644 --- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceServiceConfigurationDTO.java +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceServiceConfigurationDTO.java @@ -30,6 +30,8 @@ public class PersistenceServiceConfigurationDTO { public Collection cronStrategies = List.of(); public Collection thresholdFilters = List.of(); public Collection timeFilters = List.of(); + public Collection equalsFilters = List.of(); + public Collection includeFilters = List.of(); public boolean editable = false; } diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceEqualsFilter.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceEqualsFilter.java new file mode 100644 index 00000000000..fdceb2863a8 --- /dev/null +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceEqualsFilter.java @@ -0,0 +1,59 @@ +/** + * 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.core.persistence.filter; + +import java.util.Collection; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.items.Item; + +/** + * The {@link PersistenceEqualsFilter} is a filter that allows only specific values to pass + *

+ * The filter returns {@code false} if the string representation of the item's state is not in the given list + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class PersistenceEqualsFilter extends PersistenceFilter { + private final Collection values; + private final boolean inverted; + + public PersistenceEqualsFilter(String name, Collection values, boolean inverted) { + super(name); + this.values = values; + this.inverted = inverted; + } + + public Collection getValues() { + return values; + } + + public boolean getInverted() { + return inverted; + } + + @Override + public boolean apply(Item item) { + return values.contains(item.getState().toFullString()) != inverted; + } + + @Override + public void persisted(Item item) { + } + + @Override + public String toString() { + return String.format("%s [name=%s, value=%s, inverted=]", getClass().getSimpleName(), getName(), values); + } +} diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceIncludeFilter.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceIncludeFilter.java new file mode 100644 index 00000000000..c25aa181a3d --- /dev/null +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceIncludeFilter.java @@ -0,0 +1,100 @@ +/** + * 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.core.persistence.filter; + +import java.math.BigDecimal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.items.Item; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link PersistenceIncludeFilter} is a filter that allows only specific values to pass + *

+ * The filter returns {@code false} if the string representation of the item's state is not in the given list + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class PersistenceIncludeFilter extends PersistenceFilter { + private final Logger logger = LoggerFactory.getLogger(PersistenceIncludeFilter.class); + + private final BigDecimal lower; + private final BigDecimal upper; + private final String unit; + private final boolean inverted; + + public PersistenceIncludeFilter(String name, BigDecimal lower, BigDecimal upper, String unit, boolean inverted) { + super(name); + this.lower = lower; + this.upper = upper; + this.unit = unit; + this.inverted = inverted; + } + + public BigDecimal getLower() { + return lower; + } + + public BigDecimal getUpper() { + return upper; + } + + public String getUnit() { + return unit; + } + + public boolean getInverted() { + return inverted; + } + + @Override + public boolean apply(Item item) { + State state = item.getState(); + BigDecimal compareValue = null; + if (state instanceof DecimalType decimalType) { + compareValue = decimalType.toBigDecimal(); + } else if (state instanceof QuantityType quantityType) { + if (!unit.isBlank()) { + QuantityType convertedQuantity = quantityType.toUnit(unit); + if (convertedQuantity != null) { + compareValue = convertedQuantity.toBigDecimal(); + } + } + } + if (compareValue == null) { + logger.warn("Cannot compare {} to range {}{} - {}{} ", state, lower, unit, upper, unit); + return true; + } + + if (inverted) { + return compareValue.compareTo(lower) <= 0 || compareValue.compareTo(upper) >= 0; + } else { + return compareValue.compareTo(lower) >= 0 && compareValue.compareTo(upper) <= 0; + } + } + + @Override + public void persisted(Item item) { + } + + @Override + public String toString() { + return String.format("%s [name=%s, lower=%s, upper=%s, unit=%s, inverted=%b]", getClass().getSimpleName(), + getName(), lower, upper, unit, inverted); + } +} diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceThresholdFilter.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceThresholdFilter.java index efd3d6d968c..269c40db9d8 100644 --- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceThresholdFilter.java +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceThresholdFilter.java @@ -29,7 +29,7 @@ /** * The {@link PersistenceThresholdFilter} is a filter to prevent persistence based on a threshold. - * + *

* The filter returns {@code false} if the new value deviates by less than {@link #value}. If unit is "%" is * {@code true}, the filter returns {@code false} if the relative deviation is less than {@link #value}. * @@ -43,13 +43,15 @@ public class PersistenceThresholdFilter extends PersistenceFilter { private final BigDecimal value; private final String unit; + private final boolean relative; private final transient Map valueCache = new HashMap<>(); - public PersistenceThresholdFilter(String name, BigDecimal value, String unit) { + public PersistenceThresholdFilter(String name, BigDecimal value, String unit, boolean relative) { super(name); this.value = value; this.unit = unit; + this.relative = relative; } public BigDecimal getValue() { @@ -60,6 +62,10 @@ public String getUnit() { return unit; } + public boolean isRelative() { + return relative; + } + @Override @SuppressWarnings({ "unchecked", "rawtypes" }) public boolean apply(Item item) { @@ -78,7 +84,7 @@ public boolean apply(Item item) { if (state instanceof DecimalType) { BigDecimal oldState = ((DecimalType) cachedState).toBigDecimal(); BigDecimal delta = oldState.subtract(((DecimalType) state).toBigDecimal()); - if ("%".equals(unit) && !BigDecimal.ZERO.equals(oldState)) { + if (relative && !BigDecimal.ZERO.equals(oldState)) { delta = delta.multiply(HUNDRED).divide(oldState, 2, RoundingMode.HALF_UP); } return delta.abs().compareTo(value) > 0; @@ -86,7 +92,7 @@ public boolean apply(Item item) { try { QuantityType oldState = (QuantityType) cachedState; QuantityType delta = oldState.subtract((QuantityType) state); - if ("%".equals(unit)) { + if (relative) { if (BigDecimal.ZERO.equals(oldState.toBigDecimal())) { // value is different and old value is 0 -> always above relative threshold return true; @@ -117,6 +123,7 @@ public void persisted(Item item) { @Override public String toString() { - return String.format("%s [name=%s, value=%s, unit=%s]", getClass().getSimpleName(), getName(), value, unit); + return String.format("%s [name=%s, value=%s, unit=%s, relative=%b]", getClass().getSimpleName(), getName(), + value, unit, relative); } } diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceTimeFilter.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceTimeFilter.java index c3edcfdc6f3..524ef2bc342 100644 --- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceTimeFilter.java +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/filter/PersistenceTimeFilter.java @@ -24,8 +24,8 @@ /** * The {@link PersistenceTimeFilter} is a filter to prevent persistence base on intervals. - * - * The filter returns {@link false} if the time between now and the time of the last persisted value is less than + *

+ * The filter returns {@code false} if the time between now and the time of the last persisted value is less than * {@link #duration} {@link #unit} * * @author Jan N. Klug - Initial contribution diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/registry/PersistenceServiceConfigurationDTOMapper.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/registry/PersistenceServiceConfigurationDTOMapper.java index 2b9b513ed18..a20dbcc6d39 100644 --- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/registry/PersistenceServiceConfigurationDTOMapper.java +++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/registry/PersistenceServiceConfigurationDTOMapper.java @@ -32,7 +32,9 @@ import org.openhab.core.persistence.dto.PersistenceFilterDTO; import org.openhab.core.persistence.dto.PersistenceItemConfigurationDTO; import org.openhab.core.persistence.dto.PersistenceServiceConfigurationDTO; +import org.openhab.core.persistence.filter.PersistenceEqualsFilter; import org.openhab.core.persistence.filter.PersistenceFilter; +import org.openhab.core.persistence.filter.PersistenceIncludeFilter; import org.openhab.core.persistence.filter.PersistenceThresholdFilter; import org.openhab.core.persistence.filter.PersistenceTimeFilter; import org.openhab.core.persistence.strategy.PersistenceCronStrategy; @@ -65,6 +67,10 @@ public static PersistenceServiceConfigurationDTO map( PersistenceServiceConfigurationDTOMapper::mapPersistenceThresholdFilter); dto.timeFilters = filterList(persistenceServiceConfiguration.getFilters(), PersistenceTimeFilter.class, PersistenceServiceConfigurationDTOMapper::mapPersistenceTimeFilter); + dto.equalsFilters = filterList(persistenceServiceConfiguration.getFilters(), PersistenceEqualsFilter.class, + PersistenceServiceConfigurationDTOMapper::mapPersistenceEqualsFilter); + dto.includeFilters = filterList(persistenceServiceConfiguration.getFilters(), PersistenceIncludeFilter.class, + PersistenceServiceConfigurationDTOMapper::mapPersistenceIncludeFilter); return dto; } @@ -73,11 +79,14 @@ public static PersistenceServiceConfiguration map(PersistenceServiceConfiguratio Map strategyMap = dto.cronStrategies.stream() .collect(Collectors.toMap(e -> e.name, e -> new PersistenceCronStrategy(e.name, e.cronExpression))); - Map filterMap = Stream - .concat(dto.thresholdFilters.stream().map(f -> new PersistenceThresholdFilter(f.name, f.value, f.unit)), - dto.timeFilters.stream() - .map(f -> new PersistenceTimeFilter(f.name, f.value.intValue(), f.unit))) - .collect(Collectors.toMap(PersistenceFilter::getName, e -> e)); + Map filterMap = Stream.of( + dto.thresholdFilters.stream() + .map(f -> new PersistenceThresholdFilter(f.name, f.value, f.unit, f.relative)), + dto.timeFilters.stream().map(f -> new PersistenceTimeFilter(f.name, f.value.intValue(), f.unit)), + dto.equalsFilters.stream().map(f -> new PersistenceEqualsFilter(f.name, f.values, f.inverted)), + dto.includeFilters.stream() + .map(f -> new PersistenceIncludeFilter(f.name, f.lower, f.upper, f.unit, f.inverted))) + .flatMap(Function.identity()).collect(Collectors.toMap(PersistenceFilter::getName, e -> e)); List defaults = dto.defaults.stream() .map(str -> stringToPersistenceStrategy(str, strategyMap, dto.serviceId)).toList(); @@ -87,7 +96,9 @@ public static PersistenceServiceConfiguration map(PersistenceServiceConfiguratio .map(PersistenceServiceConfigurationDTOMapper::stringToPersistenceConfig).toList(); List strategies = config.strategies.stream() .map(str -> stringToPersistenceStrategy(str, strategyMap, dto.serviceId)).toList(); - return new PersistenceItemConfiguration(items, config.alias, strategies, List.of()); + List filters = config.filters.stream() + .map(str -> stringToPersistenceFilter(str, filterMap, dto.serviceId)).toList(); + return new PersistenceItemConfiguration(items, config.alias, strategies, filters); }).toList(); return new PersistenceServiceConfiguration(dto.serviceId, configs, defaults, strategyMap.values(), @@ -121,6 +132,16 @@ private static PersistenceStrategy stringToPersistenceStrategy(String string, throw new IllegalArgumentException("Strategy '" + string + "' unknown for service '" + serviceId + "'"); } + private static PersistenceFilter stringToPersistenceFilter(String string, Map filterMap, + String serviceId) { + PersistenceFilter filter = filterMap.get(string); + if (filter != null) { + return filter; + } + + throw new IllegalArgumentException("Filter '" + string + "' unknown for service '" + serviceId + "'"); + } + private static String persistenceConfigToString(PersistenceConfig config) { if (config instanceof PersistenceAllConfig) { return "*"; @@ -137,6 +158,7 @@ private static PersistenceItemConfigurationDTO mapPersistenceItemConfig(Persiste itemDto.items = config.items().stream().map(PersistenceServiceConfigurationDTOMapper::persistenceConfigToString) .toList(); itemDto.strategies = config.strategies().stream().map(PersistenceStrategy::getName).toList(); + itemDto.filters = config.filters().stream().map(PersistenceFilter::getName).toList(); itemDto.alias = config.alias(); return itemDto; } @@ -153,6 +175,7 @@ private static PersistenceFilterDTO mapPersistenceThresholdFilter(PersistenceThr filterDTO.name = thresholdFilter.getName(); filterDTO.value = thresholdFilter.getValue(); filterDTO.unit = thresholdFilter.getUnit(); + filterDTO.relative = thresholdFilter.isRelative(); return filterDTO; } @@ -163,4 +186,22 @@ private static PersistenceFilterDTO mapPersistenceTimeFilter(PersistenceTimeFilt filterDTO.unit = persistenceTimeFilter.getUnit(); return filterDTO; } + + private static PersistenceFilterDTO mapPersistenceEqualsFilter(PersistenceEqualsFilter persistenceEqualsFilter) { + PersistenceFilterDTO filterDTO = new PersistenceFilterDTO(); + filterDTO.name = persistenceEqualsFilter.getName(); + filterDTO.values = persistenceEqualsFilter.getValues().stream().toList(); + filterDTO.inverted = persistenceEqualsFilter.getInverted(); + return filterDTO; + } + + private static PersistenceFilterDTO mapPersistenceIncludeFilter(PersistenceIncludeFilter persistenceIncludeFilter) { + PersistenceFilterDTO filterDTO = new PersistenceFilterDTO(); + filterDTO.name = persistenceIncludeFilter.getName(); + filterDTO.lower = persistenceIncludeFilter.getLower(); + filterDTO.upper = persistenceIncludeFilter.getUpper(); + filterDTO.unit = persistenceIncludeFilter.getUnit(); + filterDTO.inverted = persistenceIncludeFilter.getInverted(); + return filterDTO; + } } diff --git a/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceEqualsFilterTest.java b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceEqualsFilterTest.java new file mode 100644 index 00000000000..2376cc59b47 --- /dev/null +++ b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceEqualsFilterTest.java @@ -0,0 +1,84 @@ +/** + * 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.core.persistence.filter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.items.GenericItem; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.types.State; + +/** + * The {@link PersistenceEqualsFilterTest} contains tests for {@link PersistenceEqualsFilter} + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class PersistenceEqualsFilterTest { + private static final String ITEM_NAME = "itemName"; + + private @NonNullByDefault({}) @Mock GenericItem item; + + @ParameterizedTest + @MethodSource("argumentProvider") + public void equalsFilterTest(State state, Collection values, boolean expected) { + when(item.getState()).thenReturn(state); + + PersistenceEqualsFilter filter = new PersistenceEqualsFilter("filter", values, false); + assertThat(filter.apply(item), is(expected)); + } + + @ParameterizedTest + @MethodSource("argumentProvider") + public void notEqualsFilterTest(State state, Collection values, boolean expected) { + when(item.getState()).thenReturn(state); + + PersistenceEqualsFilter filter = new PersistenceEqualsFilter("filter", values, true); + assertThat(filter.apply(item), is(not(expected))); + } + + private static Stream argumentProvider() { + return Stream.of(// + // item state, values, result + Arguments.of(new StringType("value1"), List.of("value1", "value2"), true), + Arguments.of(new StringType("value3"), List.of("value1", "value2"), false), + Arguments.of(new DecimalType(5), List.of("3", "5", "9"), true), + Arguments.of(new DecimalType(7), List.of("3", "5", "9"), false), + Arguments.of(new QuantityType<>(10, SIUnits.CELSIUS), List.of("5 °C", "10 °C", "15 °C"), true), + Arguments.of(new QuantityType<>(20, SIUnits.CELSIUS), List.of("5 °C", "10 °C", "15 °C"), false), + Arguments.of(OnOffType.ON, List.of("ON", "UNDEF", "NULL"), true), + Arguments.of(OnOffType.OFF, List.of("ON", "UNDEF", "NULL"), false)); + } +} diff --git a/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceIncludeFilterTest.java b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceIncludeFilterTest.java new file mode 100644 index 00000000000..a43b85cab41 --- /dev/null +++ b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceIncludeFilterTest.java @@ -0,0 +1,112 @@ +/** + * 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.core.persistence.filter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.items.GenericItem; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.types.State; + +/** + * The {@link PersistenceIncludeFilterTest} contains tests for {@link PersistenceIncludeFilter} + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class PersistenceIncludeFilterTest { + private static final String ITEM_NAME = "itemName"; + + private @NonNullByDefault({}) @Mock GenericItem item; + + @ParameterizedTest + @MethodSource("argumentProvider") + public void includeFilterTest(State state, BigDecimal lower, BigDecimal upper, String unit, boolean expected) { + when(item.getState()).thenReturn(state); + + PersistenceIncludeFilter filter = new PersistenceIncludeFilter("filter", lower, upper, unit, false); + assertThat(filter.apply(item), is(expected)); + } + + @ParameterizedTest + @MethodSource("notArgumentProvider") + public void notIncludeFilterTest(State state, BigDecimal lower, BigDecimal upper, String unit, boolean expected) { + when(item.getState()).thenReturn(state); + + PersistenceIncludeFilter filter = new PersistenceIncludeFilter("filter", lower, upper, unit, true); + assertThat(filter.apply(item), is(expected)); + } + + private static Stream argumentProvider() { + return Stream.of(// + // item state, lower, upper, unit, result + // QuantityType + Arguments.of(new QuantityType<>("17 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("17 °C"), BigDecimal.valueOf(17), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("17 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "°C", true), + Arguments.of(new QuantityType<>("10 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "°C", false), + Arguments.of(new QuantityType<>("20 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "°C", false), + Arguments.of(new QuantityType<>("0 °C"), BigDecimal.valueOf(270), BigDecimal.valueOf(275), "K", true), + // invalid or missing units + Arguments.of(new QuantityType<>("5 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("17 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("5 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "", true), + Arguments.of(new QuantityType<>("17 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "", true), + // DecimalType + Arguments.of(new DecimalType("17"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "", true), + Arguments.of(new DecimalType("17"), BigDecimal.valueOf(17), BigDecimal.valueOf(19), "", true), + Arguments.of(new DecimalType("17"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "", true), + Arguments.of(new DecimalType("10"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "", false), + Arguments.of(new DecimalType("20"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "", false)); + } + + private static Stream notArgumentProvider() { + return Stream.of(// + // item state, lower, upper, unit, result + // QuantityType + Arguments.of(new QuantityType<>("17 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "°C", false), + Arguments.of(new QuantityType<>("17 °C"), BigDecimal.valueOf(17), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("17 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "°C", true), + Arguments.of(new QuantityType<>("10 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "°C", true), + Arguments.of(new QuantityType<>("20 °C"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "°C", true), + Arguments.of(new QuantityType<>("0 °C"), BigDecimal.valueOf(270), BigDecimal.valueOf(275), "K", false), + // invalid or missing units + Arguments.of(new QuantityType<>("5 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("17 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "°C", true), + Arguments.of(new QuantityType<>("5 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "", true), + Arguments.of(new QuantityType<>("17 kg"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "", true), + // DecimalType + Arguments.of(new DecimalType("17"), BigDecimal.valueOf(14), BigDecimal.valueOf(19), "", false), + Arguments.of(new DecimalType("17"), BigDecimal.valueOf(17), BigDecimal.valueOf(19), "", true), + Arguments.of(new DecimalType("17"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "", true), + Arguments.of(new DecimalType("10"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "", true), + Arguments.of(new DecimalType("20"), BigDecimal.valueOf(14), BigDecimal.valueOf(17), "", true)); + } +} diff --git a/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceThresholdFilterTest.java b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceThresholdFilterTest.java index 15cef6acc65..e37e22c5030 100644 --- a/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceThresholdFilterTest.java +++ b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/filter/PersistenceThresholdFilterTest.java @@ -70,42 +70,43 @@ public void setup() { @Test public void differentItemSameValue() { - filterTest(ITEM_NAME_2, DecimalType.ZERO, DecimalType.ZERO, "", true); + filterTest(ITEM_NAME_2, DecimalType.ZERO, DecimalType.ZERO, "", false, true); } @ParameterizedTest @MethodSource("argumentProvider") - public void filterTest(State state1, State state2, String unit, boolean expected) { - filterTest(ITEM_NAME_1, state1, state2, unit, expected); + public void filterTest(State state1, State state2, String unit, boolean relative, boolean expected) { + filterTest(ITEM_NAME_1, state1, state2, unit, relative, expected); } private static Stream argumentProvider() { return Stream.of(// // same item, same value -> false - Arguments.of(DecimalType.ZERO, DecimalType.ZERO, "", false), + Arguments.of(DecimalType.ZERO, DecimalType.ZERO, "", false, false), // plain decimal, below threshold, absolute - Arguments.of(DecimalType.ZERO, DecimalType.valueOf("5"), "", false), + Arguments.of(DecimalType.ZERO, DecimalType.valueOf("5"), "", false, false), // plain decimal, above threshold, absolute - Arguments.of(DecimalType.ZERO, DecimalType.valueOf("15"), "", true), + Arguments.of(DecimalType.ZERO, DecimalType.valueOf("15"), "", false, true), // plain decimal, below threshold, relative - Arguments.of(DecimalType.valueOf("10.0"), DecimalType.valueOf("9.5"), "%", false), + Arguments.of(DecimalType.valueOf("10.0"), DecimalType.valueOf("9.5"), "", true, false), // plain decimal, above threshold, relative - Arguments.of(DecimalType.valueOf("10.0"), DecimalType.valueOf("11.5"), "%", true), + Arguments.of(DecimalType.valueOf("10.0"), DecimalType.valueOf("11.5"), "", true, true), // quantity type, below threshold, relative - Arguments.of(new QuantityType<>("15 A"), new QuantityType<>("14000 mA"), "%", false), + Arguments.of(new QuantityType<>("15 A"), new QuantityType<>("14000 mA"), "", true, false), // quantity type, above threshold, relative - Arguments.of(new QuantityType<>("2000 mbar"), new QuantityType<>("2.6 bar"), "%", true), + Arguments.of(new QuantityType<>("2000 mbar"), new QuantityType<>("2.6 bar"), "", true, true), // quantity type, below threshold, absolute, no unit - Arguments.of(new QuantityType<>("100 K"), new QuantityType<>("105 K"), "", false), + Arguments.of(new QuantityType<>("100 K"), new QuantityType<>("105 K"), "", false, false), // quantity type, above threshold, absolute, no unit - Arguments.of(new QuantityType<>("20 V"), new QuantityType<>("9000 mV"), "", true), + Arguments.of(new QuantityType<>("20 V"), new QuantityType<>("9000 mV"), "", false, true), // quantity type, below threshold, absolute, with unit - Arguments.of(new QuantityType<>("10 m"), new QuantityType<>("10.002 m"), "mm", false), + Arguments.of(new QuantityType<>("10 m"), new QuantityType<>("10.002 m"), "mm", false, false), // quantity type, above threshold, absolute, with unit - Arguments.of(new QuantityType<>("-10 °C"), new QuantityType<>("5 °C"), "K", true)); + Arguments.of(new QuantityType<>("-10 °C"), new QuantityType<>("5 °C"), "K", false, true)); } - private void filterTest(String item2name, State state1, State state2, String unit, boolean expected) { + private void filterTest(String item2name, State state1, State state2, String unit, boolean relative, + boolean expected) { String itemType = "Number"; if (state1 instanceof QuantityType q) { itemType += ":" + UnitUtils.getDimensionName(q.getUnit()); @@ -117,7 +118,7 @@ private void filterTest(String item2name, State state1, State state2, String uni item1.setState(state1); item2.setState(state2); - PersistenceFilter filter = new PersistenceThresholdFilter("test", BigDecimal.TEN, unit); + PersistenceFilter filter = new PersistenceThresholdFilter("test", BigDecimal.TEN, unit, relative); assertThat(filter.apply(item1), is(true)); filter.persisted(item1); diff --git a/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/internal/PersistenceManagerTest.java b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/internal/PersistenceManagerTest.java index 9172dd3e83e..b33766fbd4a 100644 --- a/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/internal/PersistenceManagerTest.java +++ b/bundles/org.openhab.core.persistence/src/test/java/org/openhab/core/persistence/internal/PersistenceManagerTest.java @@ -308,7 +308,7 @@ public void cronStrategyIsScheduledAndCancelledAndPersistsValue() throws Excepti new PersistenceCronStrategy("withoutFilter", "0 0 * * * ?"), null); addConfiguration(TEST_QUERYABLE_PERSISTENCE_SERVICE_ID, new PersistenceItemConfig(TEST_ITEM3_NAME), new PersistenceCronStrategy("withFilter", "0 * * * * ?"), - new PersistenceThresholdFilter("test", BigDecimal.TEN, "")); + new PersistenceThresholdFilter("test", BigDecimal.TEN, "", false)); manager.onReadyMarkerAdded(new ReadyMarker("", "")); @@ -352,7 +352,7 @@ public void cronStrategyIsProperlyUpdated() { @Test public void filterAppliesOnStateUpdate() { addConfiguration(TEST_PERSISTENCE_SERVICE_ID, new PersistenceAllConfig(), PersistenceStrategy.Globals.UPDATE, - new PersistenceThresholdFilter("test", BigDecimal.TEN, "")); + new PersistenceThresholdFilter("test", BigDecimal.TEN, "", false)); manager.stateUpdated(TEST_ITEM3, DecimalType.ZERO); manager.stateUpdated(TEST_ITEM3, DecimalType.ZERO); diff --git a/bundles/org.openhab.core.semantics/pom.xml b/bundles/org.openhab.core.semantics/pom.xml index 54962c3b240..1ebf3cec206 100644 --- a/bundles/org.openhab.core.semantics/pom.xml +++ b/bundles/org.openhab.core.semantics/pom.xml @@ -23,7 +23,7 @@ org.ow2.asm asm - 9.2 + 9.4 provided diff --git a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicServiceConfig.java b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicServiceConfig.java index ce8895e0774..1e46d4bf392 100644 --- a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicServiceConfig.java +++ b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicServiceConfig.java @@ -46,7 +46,7 @@ public class MagicServiceConfig { @Override public String toString() { - StringBuffer b = new StringBuffer(); + StringBuilder b = new StringBuilder(); for (Field field : this.getClass().getDeclaredFields()) { Object value; try { diff --git a/bundles/org.openhab.core.thing/pom.xml b/bundles/org.openhab.core.thing/pom.xml index 58cd26a9443..f969dcd2100 100644 --- a/bundles/org.openhab.core.thing/pom.xml +++ b/bundles/org.openhab.core.thing/pom.xml @@ -25,6 +25,11 @@ org.openhab.core.io.console ${project.version} + + org.openhab.core.bundles + org.openhab.core.transform + ${project.version} + org.openhab.core.bundles org.openhab.core.test diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelHandler.java new file mode 100644 index 00000000000..8839167d4f9 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelHandler.java @@ -0,0 +1,43 @@ +/** + * 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.core.thing.binding.generic; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.types.Command; + +/** + * The {@link ChannelHandler} defines the interface for converting received {@link ChannelHandlerContent} + * to {@link org.openhab.core.types.State}s for posting updates to {@link org.openhab.core.thing.Channel}s and + * {@link Command}s to values for sending + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public interface ChannelHandler { + + /** + * called to process a given content for this channel + * + * @param content raw content to process (null results in + * {@link org.openhab.core.types.UnDefType#UNDEF}) + */ + void process(@Nullable ChannelHandlerContent content); + + /** + * called to send a command to this channel + * + * @param command + */ + void send(Command command); +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelHandlerContent.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelHandlerContent.java new file mode 100644 index 00000000000..ed5b4ad3e15 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelHandlerContent.java @@ -0,0 +1,55 @@ +/** + * 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.core.thing.binding.generic; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link ChannelHandlerContent} defines the pre-processed response + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class ChannelHandlerContent { + private final byte[] rawContent; + private final Charset encoding; + private final @Nullable String mediaType; + + public ChannelHandlerContent(byte[] rawContent, String encoding, @Nullable String mediaType) { + this.rawContent = rawContent; + this.mediaType = mediaType; + + Charset finalEncoding = StandardCharsets.UTF_8; + try { + finalEncoding = Charset.forName(encoding); + } catch (IllegalArgumentException e) { + } + this.encoding = finalEncoding; + } + + public byte[] getRawContent() { + return rawContent; + } + + public String getAsString() { + return new String(rawContent, encoding); + } + + public @Nullable String getMediaType() { + return mediaType; + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelMode.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelMode.java new file mode 100644 index 00000000000..63353866747 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelMode.java @@ -0,0 +1,27 @@ +/** + * 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.core.thing.binding.generic; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ChannelMode} enum defines control modes for channels + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public enum ChannelMode { + READONLY, + READWRITE, + WRITEONLY +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelTransformation.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelTransformation.java new file mode 100644 index 00000000000..e4187cdae13 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelTransformation.java @@ -0,0 +1,97 @@ +/** + * 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.core.thing.binding.generic; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.transform.TransformationException; +import org.openhab.core.transform.TransformationHelper; +import org.openhab.core.transform.TransformationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ChannelTransformation} can be used to transform an input value using one or more transformations. + * Individual transformations can be chained with ∩ and must follow the pattern + * serviceName:function where serviceName refers to a {@link TransformationService} and + * function has to be a valid transformation function for this service + * + * @author Jan N. Klug - Initial contribution + */ +public class ChannelTransformation { + private final Logger logger = LoggerFactory.getLogger(ChannelTransformation.class); + private List transformationSteps; + + public ChannelTransformation(@Nullable String transformationString) { + if (transformationString != null) { + try { + transformationSteps = Arrays.stream(transformationString.split("∩")).filter(s -> !s.isBlank()) + .map(TransformationStep::new).toList(); + return; + } catch (IllegalArgumentException e) { + logger.warn("Transformation ignored, failed to parse {}: {}", transformationString, e.getMessage()); + } + } + transformationSteps = List.of(); + } + + public Optional apply(String value) { + Optional valueOptional = Optional.of(value); + + // process all transformations + for (TransformationStep transformationStep : transformationSteps) { + valueOptional = valueOptional.flatMap(transformationStep::apply); + } + + logger.trace("Transformed '{}' to '{}' using '{}'", value, valueOptional, transformationSteps); + return valueOptional; + } + + private static class TransformationStep { + private final Logger logger = LoggerFactory.getLogger(TransformationStep.class); + private final String serviceName; + private final String function; + + public TransformationStep(String pattern) throws IllegalArgumentException { + int index = pattern.indexOf(":"); + if (index == -1) { + throw new IllegalArgumentException( + "The transformation pattern must consist of the type and the pattern separated by a colon"); + } + this.serviceName = pattern.substring(0, index).toUpperCase().trim(); + this.function = pattern.substring(index + 1).trim(); + } + + public Optional apply(String value) { + TransformationService service = TransformationHelper.getTransformationService(serviceName); + if (service != null) { + try { + return Optional.ofNullable(service.transform(function, value)); + } catch (TransformationException e) { + logger.debug("Applying {} failed: {}", this, e.getMessage()); + } + } else { + logger.warn("Failed to use {}, service not found", this); + } + return Optional.empty(); + } + + @Override + public String toString() { + return "TransformationStep{serviceName='" + serviceName + "', function='" + function + "'}"; + } + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelValueConverterConfig.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelValueConverterConfig.java new file mode 100644 index 00000000000..72dd8a8a053 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/ChannelValueConverterConfig.java @@ -0,0 +1,138 @@ +/** + * 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.core.thing.binding.generic; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.NextPreviousType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.RewindFastforwardType; +import org.openhab.core.library.types.StopMoveType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.thing.binding.generic.converter.ColorChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +/** + * The {@link ChannelValueConverterConfig} is a base class for the channel configuration of things + * using the {@link ChannelHandler}s + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class ChannelValueConverterConfig { + private final Map stringStateMap = new HashMap<>(); + private final Map commandStringMap = new HashMap<>(); + + public ChannelMode mode = ChannelMode.READWRITE; + + // number + public @Nullable String unit; + + // switch, dimmer, color + public @Nullable String onValue; + public @Nullable String offValue; + + // dimmer, color + public BigDecimal step = BigDecimal.ONE; + public @Nullable String increaseValue; + public @Nullable String decreaseValue; + + // color + public ColorChannelHandler.ColorMode colorMode = ColorChannelHandler.ColorMode.RGB; + + // contact + public @Nullable String openValue; + public @Nullable String closedValue; + + // rollershutter + public @Nullable String upValue; + public @Nullable String downValue; + public @Nullable String stopValue; + public @Nullable String moveValue; + + // player + public @Nullable String playValue; + public @Nullable String pauseValue; + public @Nullable String nextValue; + public @Nullable String previousValue; + public @Nullable String rewindValue; + public @Nullable String fastforwardValue; + + private boolean initialized = false; + + /** + * maps a command to a user-defined string + * + * @param command the command to map + * @return a string or null if no mapping found + */ + public @Nullable String commandToFixedValue(Command command) { + if (!initialized) { + createMaps(); + } + + return commandStringMap.get(command); + } + + /** + * maps a user-defined string to a state + * + * @param string the string to map + * @return the state or null if no mapping found + */ + public @Nullable State fixedValueToState(String string) { + if (!initialized) { + createMaps(); + } + + return stringStateMap.get(string); + } + + private void createMaps() { + addToMaps(this.onValue, OnOffType.ON); + addToMaps(this.offValue, OnOffType.OFF); + addToMaps(this.openValue, OpenClosedType.OPEN); + addToMaps(this.closedValue, OpenClosedType.CLOSED); + addToMaps(this.upValue, UpDownType.UP); + addToMaps(this.downValue, UpDownType.DOWN); + + commandStringMap.put(IncreaseDecreaseType.INCREASE, increaseValue); + commandStringMap.put(IncreaseDecreaseType.DECREASE, decreaseValue); + commandStringMap.put(StopMoveType.STOP, stopValue); + commandStringMap.put(StopMoveType.MOVE, moveValue); + commandStringMap.put(PlayPauseType.PLAY, playValue); + commandStringMap.put(PlayPauseType.PAUSE, pauseValue); + commandStringMap.put(NextPreviousType.NEXT, nextValue); + commandStringMap.put(NextPreviousType.PREVIOUS, previousValue); + commandStringMap.put(RewindFastforwardType.REWIND, rewindValue); + commandStringMap.put(RewindFastforwardType.FASTFORWARD, fastforwardValue); + + initialized = true; + } + + private void addToMaps(@Nullable String value, State state) { + if (value != null) { + commandStringMap.put((Command) state, value); + stringStateMap.put(value, state); + } + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/ColorChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/ColorChannelHandler.java new file mode 100644 index 00000000000..c2dff0ef26f --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/ColorChannelHandler.java @@ -0,0 +1,142 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link ColorChannelHandler} implements {@link org.openhab.core.library.items.ColorItem} conversions + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class ColorChannelHandler extends AbstractTransformingChannelHandler { + private static final BigDecimal BYTE_FACTOR = BigDecimal.valueOf(2.55); + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final Pattern TRIPLE_MATCHER = Pattern.compile("(?\\d+),(?\\d+),(?\\d+)"); + + private State state = UnDefType.UNDEF; + + public ColorChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + } + + @Override + protected @Nullable Command toCommand(String value) { + return null; + } + + @Override + public String toString(Command command) { + String string = channelConfig.commandToFixedValue(command); + if (string != null) { + return string; + } + + if (command instanceof HSBType newState) { + state = newState; + return hsbToString(newState); + } else if (command instanceof PercentType percentCommand && state instanceof HSBType colorState) { + HSBType newState = new HSBType(colorState.getBrightness(), colorState.getSaturation(), percentCommand); + state = newState; + return hsbToString(newState); + } + + throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); + } + + @Override + public Optional toState(String string) { + State newState = UnDefType.UNDEF; + if (string.equals(channelConfig.onValue)) { + if (state instanceof HSBType) { + newState = new HSBType(((HSBType) state).getHue(), ((HSBType) state).getSaturation(), + PercentType.HUNDRED); + } else { + newState = HSBType.WHITE; + } + } else if (string.equals(channelConfig.offValue)) { + if (state instanceof HSBType) { + newState = new HSBType(((HSBType) state).getHue(), ((HSBType) state).getSaturation(), PercentType.ZERO); + } else { + newState = HSBType.BLACK; + } + } else if (string.equals(channelConfig.increaseValue) && state instanceof HSBType) { + BigDecimal newBrightness = ((HSBType) state).getBrightness().toBigDecimal().add(channelConfig.step); + if (HUNDRED.compareTo(newBrightness) < 0) { + newBrightness = HUNDRED; + } + newState = new HSBType(((HSBType) state).getHue(), ((HSBType) state).getSaturation(), + new PercentType(newBrightness)); + } else if (string.equals(channelConfig.decreaseValue) && state instanceof HSBType) { + BigDecimal newBrightness = ((HSBType) state).getBrightness().toBigDecimal().subtract(channelConfig.step); + if (BigDecimal.ZERO.compareTo(newBrightness) > 0) { + newBrightness = BigDecimal.ZERO; + } + newState = new HSBType(((HSBType) state).getHue(), ((HSBType) state).getSaturation(), + new PercentType(newBrightness)); + } else { + Matcher matcher = TRIPLE_MATCHER.matcher(string); + if (matcher.matches()) { + switch (channelConfig.colorMode) { + case RGB -> { + int r = Integer.parseInt(matcher.group("r")); + int g = Integer.parseInt(matcher.group("g")); + int b = Integer.parseInt(matcher.group("b")); + newState = HSBType.fromRGB(r, g, b); + } + case HSB -> newState = new HSBType(string); + } + } + } + + state = newState; + return Optional.of(newState); + } + + private String hsbToString(HSBType state) { + switch (channelConfig.colorMode) { + case RGB: + PercentType[] rgb = state.toRGB(); + return String.format("%1$d,%2$d,%3$d", rgb[0].toBigDecimal().multiply(BYTE_FACTOR).intValue(), + rgb[1].toBigDecimal().multiply(BYTE_FACTOR).intValue(), + rgb[2].toBigDecimal().multiply(BYTE_FACTOR).intValue()); + case HSB: + return state.toString(); + } + throw new IllegalStateException("Invalid colorMode setting"); + } + + public enum ColorMode { + RGB, + HSB + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/DimmerChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/DimmerChannelHandler.java new file mode 100644 index 00000000000..f9980de7831 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/DimmerChannelHandler.java @@ -0,0 +1,104 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link DimmerChannelHandler} implements {@link org.openhab.core.library.items.DimmerItem} conversions + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class DimmerChannelHandler extends AbstractTransformingChannelHandler { + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + private State state = UnDefType.UNDEF; + + public DimmerChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + } + + @Override + protected @Nullable Command toCommand(String value) { + return null; + } + + @Override + public String toString(Command command) { + String string = channelConfig.commandToFixedValue(command); + if (string != null) { + return string; + } + + if (command instanceof PercentType) { + return ((PercentType) command).toString(); + } + + throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); + } + + @Override + public Optional toState(String string) { + State newState = UnDefType.UNDEF; + + if (string.equals(channelConfig.onValue)) { + newState = PercentType.HUNDRED; + } else if (string.equals(channelConfig.offValue)) { + newState = PercentType.ZERO; + } else if (string.equals(channelConfig.increaseValue) && state instanceof PercentType) { + BigDecimal newBrightness = ((PercentType) state).toBigDecimal().add(channelConfig.step); + if (HUNDRED.compareTo(newBrightness) < 0) { + newBrightness = HUNDRED; + } + newState = new PercentType(newBrightness); + } else if (string.equals(channelConfig.decreaseValue) && state instanceof PercentType) { + BigDecimal newBrightness = ((PercentType) state).toBigDecimal().subtract(channelConfig.step); + if (BigDecimal.ZERO.compareTo(newBrightness) > 0) { + newBrightness = BigDecimal.ZERO; + } + newState = new PercentType(newBrightness); + } else { + try { + BigDecimal value = new BigDecimal(string); + if (value.compareTo(PercentType.HUNDRED.toBigDecimal()) > 0) { + value = PercentType.HUNDRED.toBigDecimal(); + } + if (value.compareTo(PercentType.ZERO.toBigDecimal()) < 0) { + value = PercentType.ZERO.toBigDecimal(); + } + newState = new PercentType(value); + } catch (NumberFormatException e) { + // ignore + } + } + + state = newState; + return Optional.of(newState); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/FixedValueMappingChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/FixedValueMappingChannelHandler.java new file mode 100644 index 00000000000..47ea726d3b6 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/FixedValueMappingChannelHandler.java @@ -0,0 +1,65 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link FixedValueMappingChannelHandler} implements mapping conversions for different item-types + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class FixedValueMappingChannelHandler extends AbstractTransformingChannelHandler { + + public FixedValueMappingChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + } + + @Override + protected @Nullable Command toCommand(String value) { + return null; + } + + @Override + public String toString(Command command) { + String value = channelConfig.commandToFixedValue(command); + if (value != null) { + return value; + } + + throw new IllegalArgumentException( + "Command type '" + command.toString() + "' not supported or mapping not defined."); + } + + @Override + public Optional toState(String string) { + State state = channelConfig.fixedValueToState(string); + + return Optional.of(Objects.requireNonNullElse(state, UnDefType.UNDEF)); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/GenericChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/GenericChannelHandler.java new file mode 100644 index 00000000000..aa25055270c --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/GenericChannelHandler.java @@ -0,0 +1,61 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link GenericChannelHandler} implements simple conversions for different item types + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class GenericChannelHandler extends AbstractTransformingChannelHandler { + private final Function toState; + + public GenericChannelHandler(Function toState, Consumer updateState, + Consumer postCommand, @Nullable Consumer sendValue, + ChannelTransformation stateTransformations, ChannelTransformation commandTransformations, + ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + this.toState = toState; + } + + protected Optional toState(String value) { + try { + return Optional.of(toState.apply(value)); + } catch (IllegalArgumentException e) { + return Optional.of(UnDefType.UNDEF); + } + } + + @Override + protected @Nullable Command toCommand(String value) { + return null; + } + + protected String toString(Command command) { + return command.toString(); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/ImageChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/ImageChannelHandler.java new file mode 100644 index 00000000000..ff23ea3289b --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/ImageChannelHandler.java @@ -0,0 +1,55 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.RawType; +import org.openhab.core.thing.binding.generic.ChannelHandler; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link ImageChannelHandler} implements {@link org.openhab.core.library.items.ImageItem} conversions + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class ImageChannelHandler implements ChannelHandler { + private final Consumer updateState; + + public ImageChannelHandler(Consumer updateState) { + this.updateState = updateState; + } + + @Override + public void process(@Nullable ChannelHandlerContent content) { + if (content == null) { + updateState.accept(UnDefType.UNDEF); + return; + } + String mediaType = content.getMediaType(); + updateState.accept( + new RawType(content.getRawContent(), mediaType != null ? mediaType : RawType.DEFAULT_MIME_TYPE)); + } + + @Override + public void send(Command command) { + throw new IllegalStateException("Read-only channel"); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/NumberChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/NumberChannelHandler.java new file mode 100644 index 00000000000..91a48514d4c --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/NumberChannelHandler.java @@ -0,0 +1,79 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.util.Optional; +import java.util.function.Consumer; + +import javax.measure.format.MeasurementParseException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link NumberChannelHandler} implements {@link org.openhab.core.library.items.NumberItem} conversions + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class NumberChannelHandler extends AbstractTransformingChannelHandler { + + public NumberChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + } + + @Override + protected @Nullable Command toCommand(String value) { + return null; + } + + @Override + protected Optional toState(String value) { + String trimmedValue = value.trim(); + State newState = UnDefType.UNDEF; + if (!trimmedValue.isEmpty()) { + try { + if (channelConfig.unit != null) { + // we have a given unit - use that + newState = new QuantityType<>(trimmedValue + " " + channelConfig.unit); + } else { + try { + // try if we have a simple number + newState = new DecimalType(trimmedValue); + } catch (IllegalArgumentException e1) { + // not a plain number, maybe with unit? + newState = new QuantityType<>(trimmedValue); + } + } + } catch (IllegalArgumentException | MeasurementParseException e) { + // finally failed + } + } + return Optional.of(newState); + } + + @Override + protected String toString(Command command) { + return command.toString(); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/PlayerChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/PlayerChannelHandler.java new file mode 100644 index 00000000000..0afe6f41c18 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/PlayerChannelHandler.java @@ -0,0 +1,86 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.NextPreviousType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.RewindFastforwardType; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; + +/** + * The {@link PlayerChannelHandler} implements {@link org.openhab.core.library.items.RollershutterItem} + * conversions + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class PlayerChannelHandler extends AbstractTransformingChannelHandler { + private @Nullable String lastCommand; // store last command to prevent duplicate commands + + public PlayerChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + } + + @Override + public String toString(Command command) { + String string = channelConfig.commandToFixedValue(command); + if (string != null) { + return string; + } + + throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); + } + + @Override + protected @Nullable Command toCommand(String string) { + if (string.equals(lastCommand)) { + // only send commands once + return null; + } + lastCommand = string; + + if (string.equals(channelConfig.playValue)) { + return PlayPauseType.PLAY; + } else if (string.equals(channelConfig.pauseValue)) { + return PlayPauseType.PAUSE; + } else if (string.equals(channelConfig.nextValue)) { + return NextPreviousType.NEXT; + } else if (string.equals(channelConfig.previousValue)) { + return NextPreviousType.PREVIOUS; + } else if (string.equals(channelConfig.rewindValue)) { + return RewindFastforwardType.REWIND; + } else if (string.equals(channelConfig.fastforwardValue)) { + return RewindFastforwardType.FASTFORWARD; + } + + return null; + } + + @Override + public Optional toState(String string) { + // no value - we ignore state updates + return Optional.empty(); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/RollershutterChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/RollershutterChannelHandler.java new file mode 100644 index 00000000000..cf0ef1df0a6 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/generic/converter/RollershutterChannelHandler.java @@ -0,0 +1,102 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.StopMoveType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link RollershutterChannelHandler} implements {@link org.openhab.core.library.items.RollershutterItem} + * conversions + * + * @author Jan N. Klug - Initial contribution + */ + +@NonNullByDefault +public class RollershutterChannelHandler extends AbstractTransformingChannelHandler { + + public RollershutterChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + super(updateState, postCommand, sendValue, stateTransformations, commandTransformations, channelConfig); + } + + @Override + public String toString(Command command) { + String string = channelConfig.commandToFixedValue(command); + if (string != null) { + return string; + } + + if (command instanceof PercentType) { + final String downValue = channelConfig.downValue; + final String upValue = channelConfig.upValue; + if (command.equals(PercentType.HUNDRED) && downValue != null) { + return downValue; + } else if (command.equals(PercentType.ZERO) && upValue != null) { + return upValue; + } else { + return ((PercentType) command).toString(); + } + } + + throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); + } + + @Override + protected @Nullable Command toCommand(String string) { + if (string.equals(channelConfig.upValue)) { + return UpDownType.UP; + } else if (string.equals(channelConfig.downValue)) { + return UpDownType.DOWN; + } else if (string.equals(channelConfig.moveValue)) { + return StopMoveType.MOVE; + } else if (string.equals(channelConfig.stopValue)) { + return StopMoveType.STOP; + } + + return null; + } + + @Override + public Optional toState(String string) { + State newState = UnDefType.UNDEF; + try { + BigDecimal value = new BigDecimal(string); + if (value.compareTo(PercentType.HUNDRED.toBigDecimal()) > 0) { + value = PercentType.HUNDRED.toBigDecimal(); + } + if (value.compareTo(PercentType.ZERO.toBigDecimal()) < 0) { + value = PercentType.ZERO.toBigDecimal(); + } + newState = new PercentType(value); + } catch (NumberFormatException e) { + // ignore + } + + return Optional.of(newState); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/binding/generic/converter/AbstractTransformingChannelHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/binding/generic/converter/AbstractTransformingChannelHandler.java new file mode 100644 index 00000000000..b4e332f0c9a --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/binding/generic/converter/AbstractTransformingChannelHandler.java @@ -0,0 +1,115 @@ +/** + * 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.core.thing.internal.binding.generic.converter; + +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.binding.generic.ChannelHandler; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; +import org.openhab.core.thing.binding.generic.ChannelMode; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link AbstractTransformingChannelHandler} is a base class for an item converter with transformations + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractTransformingChannelHandler implements ChannelHandler { + private final Consumer updateState; + private final Consumer postCommand; + private final @Nullable Consumer sendValue; + private final ChannelTransformation stateTransformations; + private final ChannelTransformation commandTransformations; + + protected final ChannelValueConverterConfig channelConfig; + + public AbstractTransformingChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig) { + this.updateState = updateState; + this.postCommand = postCommand; + this.sendValue = sendValue; + this.stateTransformations = stateTransformations; + this.commandTransformations = commandTransformations; + this.channelConfig = channelConfig; + } + + @Override + public void process(@Nullable ChannelHandlerContent content) { + if (content == null) { + updateState.accept(UnDefType.UNDEF); + return; + } + if (channelConfig.mode != ChannelMode.WRITEONLY) { + stateTransformations.apply(content.getAsString()).ifPresent(transformedValue -> { + Command command = toCommand(transformedValue); + if (command != null) { + postCommand.accept(command); + } else { + toState(transformedValue).ifPresent(updateState); + } + }); + } else { + throw new IllegalStateException("Write-only channel"); + } + } + + @Override + public void send(Command command) { + Consumer sendHttpValue = this.sendValue; + if (sendHttpValue != null && channelConfig.mode != ChannelMode.READONLY) { + commandTransformations.apply(toString(command)).ifPresent(sendHttpValue); + } else { + throw new IllegalStateException("Read-only channel"); + } + } + + /** + * check if this converter received a value that needs to be sent as command + * + * @param value the value + * @return the command or null + */ + protected abstract @Nullable Command toCommand(String value); + + /** + * convert the received value to a state + * + * @param value the value + * @return the state that represents the value of UNDEF if conversion failed + */ + protected abstract Optional toState(String value); + + /** + * convert a command to a string + * + * @param command the command + * @return the string representation of the command + */ + protected abstract String toString(Command command); + + @FunctionalInterface + public interface Factory { + ChannelHandler create(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendHttpValue, ChannelTransformation stateTransformations, + ChannelTransformation commandTransformations, ChannelValueConverterConfig channelConfig); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/console/FirmwareUpdateConsoleCommandExtension.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/console/FirmwareUpdateConsoleCommandExtension.java index 3e91ea4b99d..b102afba530 100644 --- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/console/FirmwareUpdateConsoleCommandExtension.java +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/console/FirmwareUpdateConsoleCommandExtension.java @@ -132,7 +132,7 @@ private void listFirmwareStatus(Console console, String[] args) { FirmwareStatusInfo firmwareStatusInfo = firmwareUpdateService.getFirmwareStatusInfo(thingUID); if (firmwareStatusInfo != null) { - StringBuffer sb = new StringBuffer(); + StringBuilder sb = new StringBuilder(); sb.append(String.format("Firmware status for thing with UID %s is %s.", thingUID, firmwareStatusInfo.getFirmwareStatus())); diff --git a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/binding/generic/ChannelTransformationTest.java b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/binding/generic/ChannelTransformationTest.java new file mode 100644 index 00000000000..d7847b63228 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/binding/generic/ChannelTransformationTest.java @@ -0,0 +1,148 @@ +/** + * 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.core.thing.binding.generic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.transform.TransformationException; +import org.openhab.core.transform.TransformationHelper; +import org.openhab.core.transform.TransformationService; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; + +/** + * The {@link ChannelTransformationTest} contains tests for the {@link ChannelTransformation} + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class ChannelTransformationTest { + private static final String T1_NAME = "TRANSFORM1"; + private static final String T1_PATTERN = "T1Pattern"; + private static final String T1_INPUT = "T1Input"; + private static final String T1_RESULT = "T1Result"; + + private static final String T2_NAME = "TRANSFORM2"; + private static final String T2_PATTERN = "T2Pattern"; + private static final String T2_INPUT = T1_RESULT; + private static final String T2_RESULT = "T2Result"; + + private @Mock @NonNullByDefault({}) TransformationService transformationService1Mock; + private @Mock @NonNullByDefault({}) TransformationService transformationService2Mock; + + private @Mock @NonNullByDefault({}) BundleContext bundleContextMock; + private @Mock @NonNullByDefault({}) ServiceReference serviceRef1Mock; + private @Mock @NonNullByDefault({}) ServiceReference serviceRef2Mock; + + private @NonNullByDefault({}) TransformationHelper transformationHelper; + + @BeforeEach + public void init() throws TransformationException { + Mockito.when(transformationService1Mock.transform(eq(T1_PATTERN), eq(T1_INPUT))) + .thenAnswer(answer -> T1_RESULT); + Mockito.when(transformationService2Mock.transform(eq(T2_PATTERN), eq(T1_INPUT))) + .thenAnswer(answer -> T2_RESULT); + Mockito.when(transformationService2Mock.transform(eq(T2_PATTERN), eq(T2_INPUT))) + .thenAnswer(answer -> T2_RESULT); + + Mockito.when(serviceRef1Mock.getProperty(any())).thenReturn("TRANSFORM1"); + Mockito.when(serviceRef2Mock.getProperty(any())).thenReturn("TRANSFORM2"); + + Mockito.when(bundleContextMock.getService(serviceRef1Mock)).thenReturn(transformationService1Mock); + Mockito.when(bundleContextMock.getService(serviceRef2Mock)).thenReturn(transformationService2Mock); + + transformationHelper = new TransformationHelper(bundleContextMock); + transformationHelper.setTransformationService(serviceRef1Mock); + transformationHelper.setTransformationService(serviceRef2Mock); + } + + @AfterEach + public void tearDown() { + transformationHelper.deactivate(); + } + + @Test + public void testMissingTransformation() { + String pattern = "TRANSFORM:pattern"; + + ChannelTransformation transformation = new ChannelTransformation(pattern); + String result = transformation.apply(T1_INPUT).orElse(null); + + assertNull(result); + } + + @Test + public void testSingleTransformation() { + String pattern = T1_NAME + ":" + T1_PATTERN; + + ChannelTransformation transformation = new ChannelTransformation(pattern); + String result = transformation.apply(T1_INPUT).orElse(null); + + assertEquals(T1_RESULT, result); + } + + @Test + public void testInvalidFirstTransformation() { + String pattern = T1_NAME + "X:" + T1_PATTERN + "∩" + T2_NAME + ":" + T2_PATTERN; + + ChannelTransformation transformation = new ChannelTransformation(pattern); + String result = transformation.apply(T1_INPUT).orElse(null); + + assertNull(result); + } + + @Test + public void testInvalidSecondTransformation() { + String pattern = T1_NAME + ":" + T1_PATTERN + "∩" + T2_NAME + "X:" + T2_PATTERN; + + ChannelTransformation transformation = new ChannelTransformation(pattern); + String result = transformation.apply(T1_INPUT).orElse(null); + + assertNull(result); + } + + @Test + public void testDoubleTransformationWithoutSpaces() { + String pattern = T1_NAME + ":" + T1_PATTERN + "∩" + T2_NAME + ":" + T2_PATTERN; + + ChannelTransformation transformation = new ChannelTransformation(pattern); + String result = transformation.apply(T1_INPUT).orElse(null); + + assertEquals(T2_RESULT, result); + } + + @Test + public void testDoubleTransformationWithSpaces() { + String pattern = " " + T1_NAME + " : " + T1_PATTERN + " ∩ " + T2_NAME + " : " + T2_PATTERN + " "; + + ChannelTransformation transformation = new ChannelTransformation(pattern); + String result = transformation.apply(T1_INPUT).orElse(null); + + assertEquals(T2_RESULT, result); + } +} diff --git a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/binding/generic/converter/ConverterTest.java b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/binding/generic/converter/ConverterTest.java new file mode 100644 index 00000000000..1d5e2e6b24e --- /dev/null +++ b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/binding/generic/converter/ConverterTest.java @@ -0,0 +1,186 @@ +/** + * 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.core.thing.binding.generic.converter; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link ConverterTest} is a test class for state converters + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class ConverterTest { + + private @Mock @NonNullByDefault({}) Consumer sendValueMock; + + private @Mock @NonNullByDefault({}) Consumer updateStateMock; + + private @Mock @NonNullByDefault({}) Consumer postCommandMock; + + @Test + public void numberItemConverter() { + NumberChannelHandler converter = new NumberChannelHandler(updateStateMock, postCommandMock, sendValueMock, + new ChannelTransformation(null), new ChannelTransformation(null), new ChannelValueConverterConfig()); + + // without unit + Assertions.assertEquals(Optional.of(new DecimalType(1234)), converter.toState("1234")); + + // unit in transformation result + Assertions.assertEquals(Optional.of(new QuantityType<>(100, SIUnits.CELSIUS)), converter.toState("100°C")); + + // no valid value + Assertions.assertEquals(Optional.of(UnDefType.UNDEF), converter.toState("W")); + Assertions.assertEquals(Optional.of(UnDefType.UNDEF), converter.toState("")); + } + + @Test + public void numberItemConverterWithUnit() { + ChannelValueConverterConfig channelConfig = new ChannelValueConverterConfig(); + channelConfig.unit = "W"; + NumberChannelHandler converter = new NumberChannelHandler(updateStateMock, postCommandMock, sendValueMock, + new ChannelTransformation(null), new ChannelTransformation(null), channelConfig); + + // without unit + Assertions.assertEquals(Optional.of(new QuantityType<>(500, Units.WATT)), converter.toState("500")); + + // no valid value + Assertions.assertEquals(Optional.of(UnDefType.UNDEF), converter.toState("foo")); + Assertions.assertEquals(Optional.of(UnDefType.UNDEF), converter.toState("")); + } + + @Test + public void stringTypeConverter() { + GenericChannelHandler converter = createConverter(StringType::new); + Assertions.assertEquals(Optional.of(new StringType("Test")), converter.toState("Test")); + } + + @Test + public void decimalTypeConverter() { + GenericChannelHandler converter = createConverter(DecimalType::new); + Assertions.assertEquals(Optional.of(new DecimalType(15.6)), converter.toState("15.6")); + } + + @Test + public void pointTypeConverter() { + GenericChannelHandler converter = createConverter(PointType::new); + Assertions.assertEquals( + Optional.of(new PointType(new DecimalType(51.1), new DecimalType(7.2), new DecimalType(100))), + converter.toState("51.1, 7.2, 100")); + } + + @Test + public void playerItemTypeConverter() { + ChannelValueConverterConfig cfg = new ChannelValueConverterConfig(); + cfg.playValue = "PLAY"; + ChannelHandlerContent content = new ChannelHandlerContent("PLAY".getBytes(StandardCharsets.UTF_8), "UTF-8", + null); + PlayerChannelHandler converter = new PlayerChannelHandler(updateStateMock, postCommandMock, sendValueMock, + new ChannelTransformation(null), new ChannelTransformation(null), cfg); + converter.process(content); + converter.process(content); + + Mockito.verify(postCommandMock).accept(PlayPauseType.PLAY); + Mockito.verify(updateStateMock, Mockito.never()).accept(ArgumentMatchers.any()); + } + + @Test + public void colorItemTypeRGBConverter() { + ChannelValueConverterConfig cfg = new ChannelValueConverterConfig(); + cfg.colorMode = ColorChannelHandler.ColorMode.RGB; + ChannelHandlerContent content = new ChannelHandlerContent("123,34,47".getBytes(StandardCharsets.UTF_8), "UTF-8", + null); + ColorChannelHandler converter = new ColorChannelHandler(updateStateMock, postCommandMock, sendValueMock, + new ChannelTransformation(null), new ChannelTransformation(null), cfg); + + converter.process(content); + Mockito.verify(updateStateMock).accept(HSBType.fromRGB(123, 34, 47)); + } + + @Test + public void colorItemTypeHSBConverter() { + ChannelValueConverterConfig cfg = new ChannelValueConverterConfig(); + cfg.colorMode = ColorChannelHandler.ColorMode.HSB; + ChannelHandlerContent content = new ChannelHandlerContent("123,34,47".getBytes(StandardCharsets.UTF_8), "UTF-8", + null); + ColorChannelHandler converter = new ColorChannelHandler(updateStateMock, postCommandMock, sendValueMock, + new ChannelTransformation(null), new ChannelTransformation(null), cfg); + + converter.process(content); + Mockito.verify(updateStateMock).accept(new HSBType("123,34,47")); + } + + @Test + public void rollerSHutterConverter() { + ChannelValueConverterConfig cfg = new ChannelValueConverterConfig(); + RollershutterChannelHandler converter = new RollershutterChannelHandler(updateStateMock, postCommandMock, + sendValueMock, new ChannelTransformation(null), new ChannelTransformation(null), cfg); + + // test 0 and 100 + ChannelHandlerContent content = new ChannelHandlerContent("0".getBytes(StandardCharsets.UTF_8), "UTF-8", null); + converter.process(content); + Mockito.verify(updateStateMock).accept(PercentType.ZERO); + content = new ChannelHandlerContent("100".getBytes(StandardCharsets.UTF_8), "UTF-8", null); + converter.process(content); + Mockito.verify(updateStateMock).accept(PercentType.HUNDRED); + + // test under/over-range (expect two times total for zero/100 + content = new ChannelHandlerContent("-1".getBytes(StandardCharsets.UTF_8), "UTF-8", null); + converter.process(content); + Mockito.verify(updateStateMock, Mockito.times(2)).accept(PercentType.ZERO); + content = new ChannelHandlerContent("105".getBytes(StandardCharsets.UTF_8), "UTF-8", null); + converter.process(content); + Mockito.verify(updateStateMock, Mockito.times(2)).accept(PercentType.HUNDRED); + + // test value + content = new ChannelHandlerContent("67".getBytes(StandardCharsets.UTF_8), "UTF-8", null); + converter.process(content); + Mockito.verify(updateStateMock).accept(new PercentType(67)); + } + + public GenericChannelHandler createConverter(Function fcn) { + return new GenericChannelHandler(fcn, updateStateMock, postCommandMock, sendValueMock, + new ChannelTransformation(null), new ChannelTransformation(null), new ChannelValueConverterConfig()); + } +} diff --git a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/binding/generic/converter/AbstractTransformingItemConverterTest.java b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/binding/generic/converter/AbstractTransformingItemConverterTest.java new file mode 100644 index 00000000000..08207319d4a --- /dev/null +++ b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/binding/generic/converter/AbstractTransformingItemConverterTest.java @@ -0,0 +1,172 @@ +/** + * 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.core.thing.internal.binding.generic.converter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link AbstractTransformingItemConverterTest} is a test class for the + * {@link AbstractTransformingChannelHandler} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class AbstractTransformingItemConverterTest { + + @Mock + private @NonNullByDefault({}) Consumer sendHttpValue; + + @Mock + private @NonNullByDefault({}) Consumer updateState; + + @Mock + private @NonNullByDefault({}) Consumer postCommand; + + private @NonNullByDefault({}) AutoCloseable closeable; + + @Spy + private ChannelTransformation stateChannelTransformation = new ChannelTransformation(null); + + @Spy + private ChannelTransformation commandChannelTransformation = new ChannelTransformation(null); + + @BeforeEach + public void init() { + closeable = MockitoAnnotations.openMocks(this); + } + + @AfterEach + public void close() throws Exception { + closeable.close(); + } + + @Test + public void undefOnNullContentTest() { + TestChannelHandler realConverter = new TestChannelHandler(updateState, postCommand, sendHttpValue, + stateChannelTransformation, commandChannelTransformation, false); + TestChannelHandler converter = spy(realConverter); + + converter.process(null); + // make sure UNDEF is send as state update + verify(updateState, only()).accept(UnDefType.UNDEF); + verify(postCommand, never()).accept(any()); + verify(sendHttpValue, never()).accept(any()); + + // make sure no other processing applies + verify(converter, never()).toState(any()); + verify(converter, never()).toCommand(any()); + verify(converter, never()).toString(any()); + } + + @Test + public void commandIsPostedAsCommand() { + TestChannelHandler converter = new TestChannelHandler(updateState, postCommand, sendHttpValue, + stateChannelTransformation, commandChannelTransformation, true); + + converter.process(new ChannelHandlerContent("TEST".getBytes(StandardCharsets.UTF_8), "", null)); + + // check state transformation is applied + verify(stateChannelTransformation).apply(any()); + verify(commandChannelTransformation, never()).apply(any()); + + // check only postCommand is applied + verify(updateState, never()).accept(any()); + verify(postCommand, only()).accept(new StringType("TEST")); + verify(sendHttpValue, never()).accept(any()); + } + + @Test + public void updateIsPostedAsUpdate() { + TestChannelHandler converter = new TestChannelHandler(updateState, postCommand, sendHttpValue, + stateChannelTransformation, commandChannelTransformation, false); + + converter.process(new ChannelHandlerContent("TEST".getBytes(StandardCharsets.UTF_8), "", null)); + + // check state transformation is applied + verify(stateChannelTransformation).apply(any()); + verify(commandChannelTransformation, never()).apply(any()); + + // check only updateState is called + verify(updateState, only()).accept(new StringType("TEST")); + verify(postCommand, never()).accept(any()); + verify(sendHttpValue, never()).accept(any()); + } + + @Test + public void sendCommandSendsCommand() { + TestChannelHandler converter = new TestChannelHandler(updateState, postCommand, sendHttpValue, + stateChannelTransformation, commandChannelTransformation, false); + + converter.send(new StringType("TEST")); + + // check command transformation is applied + verify(stateChannelTransformation, never()).apply(any()); + verify(commandChannelTransformation).apply(any()); + + // check only sendHttpValue is applied + verify(updateState, never()).accept(any()); + verify(postCommand, never()).accept(any()); + verify(sendHttpValue, only()).accept("TEST"); + } + + private static class TestChannelHandler extends AbstractTransformingChannelHandler { + private boolean hasCommand; + + public TestChannelHandler(Consumer updateState, Consumer postCommand, + @Nullable Consumer sendValue, ChannelTransformation stateChannelTransformation, + ChannelTransformation commandChannelTransformation, boolean hasCommand) { + super(updateState, postCommand, sendValue, stateChannelTransformation, commandChannelTransformation, + new ChannelValueConverterConfig()); + this.hasCommand = hasCommand; + } + + @Override + protected @Nullable Command toCommand(String value) { + return hasCommand ? new StringType(value) : null; + } + + @Override + protected Optional toState(String value) { + return Optional.of(new StringType(value)); + } + + @Override + protected String toString(Command command) { + return command.toString(); + } + } +} diff --git a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationHelper.java b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationHelper.java index 93500be5e19..94e953d7253 100644 --- a/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationHelper.java +++ b/bundles/org.openhab.core.transform/src/main/java/org/openhab/core/transform/TransformationHelper.java @@ -12,25 +12,34 @@ */ package org.openhab.core.transform; -import java.util.Collection; import java.util.IllegalFormatException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.osgi.framework.BundleContext; -import org.osgi.framework.InvalidSyntaxException; import org.osgi.framework.ServiceReference; +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.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * * @author Kai Kreuzer - Initial contribution + * @author Jan N. Klug - Refactored to OSGi service */ +@Component(immediate = true) @NonNullByDefault public class TransformationHelper { + private static final Map SERVICES = new ConcurrentHashMap<>(); private static final Logger LOGGER = LoggerFactory.getLogger(TransformationHelper.class); @@ -40,6 +49,35 @@ public class TransformationHelper { protected static final Pattern EXTRACT_TRANSFORMFUNCTION_PATTERN = Pattern .compile("(.*?)\\((.*)\\)" + FUNCTION_VALUE_DELIMITER + "(.*)"); + private final BundleContext bundleContext; + + @Activate + public TransformationHelper(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + @Deactivate + public void deactivate() { + SERVICES.clear(); + } + + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + public void setTransformationService(ServiceReference ref) { + String key = (String) ref.getProperty(TransformationService.SERVICE_PROPERTY_NAME); + TransformationService service = bundleContext.getService(ref); + if (service != null) { + SERVICES.put(key, service); + LOGGER.debug("Added transformation service {}", key); + } + } + + public void unsetTransformationService(ServiceReference ref) { + String key = (String) ref.getProperty(TransformationService.SERVICE_PROPERTY_NAME); + if (SERVICES.remove(key) != null) { + LOGGER.debug("Removed transformation service {}", key); + } + } + /** * determines whether a pattern refers to a transformation service * @@ -50,52 +88,57 @@ public static boolean isTransform(String pattern) { return EXTRACT_TRANSFORMFUNCTION_PATTERN.matcher(pattern).matches(); } + public static @Nullable TransformationService getTransformationService(String serviceName) { + return SERVICES.get(serviceName); + } + /** - * Queries the OSGi service registry for a service that provides a transformation service of - * a given transformation type (e.g. REGEX, XSLT, etc.) + * Return the transformation service that provides a given transformation type (e.g. REGEX, XSLT, etc.) * * @param context the bundle context which can be null * @param transformationType the desired transformation type * @return a service instance or null, if none could be found + * + * @deprecated use {@link #getTransformationService(String)} instead */ + @Deprecated public static @Nullable TransformationService getTransformationService(@Nullable BundleContext context, String transformationType) { - if (context != null) { - String filter = "(" + TransformationService.SERVICE_PROPERTY_NAME + "=" + transformationType + ")"; - try { - Collection> refs = context - .getServiceReferences(TransformationService.class, filter); - if (refs != null && !refs.isEmpty()) { - return context.getService(refs.iterator().next()); - } else { - LOGGER.debug("Cannot get service reference for transformation service of type {}", - transformationType); - } - } catch (InvalidSyntaxException e) { - LOGGER.debug("Cannot get service reference for transformation service of type {}", transformationType, - e); - } - } - return null; + return getTransformationService(transformationType); } /** * Transforms a state string using transformation functions within a given pattern. * * @param context a valid bundle context, required for accessing the services - * @param stateDescPattern the pattern that contains the transformation instructions + * @param transformationString the pattern that contains the transformation instructions * @param state the state to be formatted before being passed into the transformation function * @return the result of the transformation. If no transformation was done, null is returned * @throws TransformationException if transformation service is not available or the transformation failed + * + * @deprecated Use {@link #transform(String, String)} instead */ - public static @Nullable String transform(BundleContext context, String stateDescPattern, String state) + @Deprecated + public static @Nullable String transform(BundleContext context, String transformationString, String state) throws TransformationException { - Matcher matcher = EXTRACT_TRANSFORMFUNCTION_PATTERN.matcher(stateDescPattern); + return transform(transformationString, state); + } + + /** + * Transforms a state string using transformation functions within a given pattern. + * + * @param transformationString the pattern that contains the transformation instructions + * @param state the state to be formatted before being passed into the transformation function + * @return the result of the transformation. If no transformation was done, null is returned + * @throws TransformationException if transformation service is not available or the transformation failed + */ + public static @Nullable String transform(String transformationString, String state) throws TransformationException { + Matcher matcher = EXTRACT_TRANSFORMFUNCTION_PATTERN.matcher(transformationString); if (matcher.find()) { String type = matcher.group(1); String pattern = matcher.group(2); String value = matcher.group(3); - TransformationService transformation = TransformationHelper.getTransformationService(context, type); + TransformationService transformation = SERVICES.get(type); if (transformation != null) { return transform(transformation, pattern, value, state); } else { diff --git a/bundles/org.openhab.core.ui/NOTICE b/bundles/org.openhab.core.ui/NOTICE index f4cf4cb3fa1..c9355858dd6 100644 --- a/bundles/org.openhab.core.ui/NOTICE +++ b/bundles/org.openhab.core.ui/NOTICE @@ -15,10 +15,10 @@ https://github.com/openhab/openhab-core == Third-party Content -xchart-2.6.1 +xchart-3.8.4 * License: Apache License, 2.0 * Project: http://knowm.org/open-source/xchart -* Source: https://github.com/timmolter/XChart/tree/xchart-2.6.1 +* Source: https://github.com/timmolter/XChart/tree/xchart-3.8.4 == Third-party license(s) diff --git a/bundles/org.openhab.core.ui/bnd.bnd b/bundles/org.openhab.core.ui/bnd.bnd index c13a3330e6a..d6f226152e2 100644 --- a/bundles/org.openhab.core.ui/bnd.bnd +++ b/bundles/org.openhab.core.ui/bnd.bnd @@ -4,4 +4,7 @@ Private-Package: \ org.knowm.xchart.* Import-Package: \ de.erichseifert.vectorgraphics2d.*;resolution:=optional,\ + de.rototor.pdfbox.*;resolution:=optional,\ + com.madgag.*;resolution:=optional,\ + org.apache.pdfbox.*;resolution:=optional,\ * \ No newline at end of file diff --git a/bundles/org.openhab.core.ui/pom.xml b/bundles/org.openhab.core.ui/pom.xml index 575615ef393..2eef0e13fbb 100644 --- a/bundles/org.openhab.core.ui/pom.xml +++ b/bundles/org.openhab.core.ui/pom.xml @@ -18,8 +18,7 @@ org.knowm.xchart xchart - - 3.1.0 + 3.8.4 org.openhab.core.bundles diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/chart/defaultchartprovider/DefaultChartProvider.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/chart/defaultchartprovider/DefaultChartProvider.java index 1c8304a336e..5f89cb04859 100644 --- a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/chart/defaultchartprovider/DefaultChartProvider.java +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/chart/defaultchartprovider/DefaultChartProvider.java @@ -32,7 +32,7 @@ import org.knowm.xchart.XYChart; import org.knowm.xchart.XYChartBuilder; import org.knowm.xchart.XYSeries; -import org.knowm.xchart.style.Styler; +import org.knowm.xchart.style.AxesChartStyler; import org.knowm.xchart.style.Styler.LegendPosition; import org.knowm.xchart.style.XYStyler; import org.knowm.xchart.style.markers.None; @@ -195,7 +195,7 @@ public BufferedImage createChart(@Nullable String serviceId, @Nullable String th styler.setYAxisDecimalPattern(yAxisDecimalPattern); } styler.setYAxisTickMarkSpacingHint(yAxisSpacing); - styler.setYAxisLabelAlignment(Styler.TextAlignment.Right); + styler.setYAxisLabelAlignment(AxesChartStyler.TextAlignment.Right); // chart styler.setChartBackgroundColor(chartTheme.getChartBackgroundColor()); styler.setChartFontColor(chartTheme.getChartFontColor()); diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/items/ItemUIRegistryImpl.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/items/ItemUIRegistryImpl.java index 4be4185cddc..1862082e2e8 100644 --- a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/items/ItemUIRegistryImpl.java +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/internal/items/ItemUIRegistryImpl.java @@ -108,6 +108,7 @@ * @author Stefan Triller - Method to convert a state into something a sitemap entity can understand * @author Erdoan Hadzhiyusein - Adapted the class to work with the new DateTimeType * @author Laurent Garnier - new method getIconColor + * @author Mark Herwege - new method getFormatPattern(widget), clean pattern */ @NonNullByDefault @Component(immediate = true, configurationPid = "org.openhab.sitemap", // @@ -121,9 +122,9 @@ public class ItemUIRegistryImpl implements ItemUIRegistry { protected static final Pattern EXTRACT_TRANSFORM_FUNCTION_PATTERN = Pattern.compile("(.*?)\\((.*)\\):(.*)"); /* RegEx to identify format patterns. See java.util.Formatter#formatSpecifier (without the '%' at the very end). */ - protected static final String IDENTIFY_FORMAT_PATTERN_PATTERN = "%((unit%)|((\\d+\\$)?([-#+ 0,(<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z])))"; + protected static final String IDENTIFY_FORMAT_PATTERN_PATTERN = "%(?:(unit%)|(?:(?:\\d+\\$)?(?:[-#+ 0,(<]*)?(?:\\d+)?(?:\\.\\d+)?(?:[tT])?(?:[a-zA-Z])))"; + private static final Pattern FORMAT_PATTERN = Pattern.compile("(?:^|[^%])" + IDENTIFY_FORMAT_PATTERN_PATTERN); - private static final Pattern LABEL_PATTERN = Pattern.compile(".*?\\[.*? (.*?)]"); private static final int MAX_BUTTONS = 4; private static final String DEFAULT_SORTING = "NONE"; @@ -331,10 +332,14 @@ private Switch createPlayerButtons() { String labelMappedOption = null; State state = null; StateDescription stateDescription = null; - String formatPattern = getFormatPattern(label); + String formatPattern = getFormatPattern(w); + + if (formatPattern != null && label.indexOf("[") < 0) { + label = label + " [" + formatPattern + "]"; + } // now insert the value, if the state is a string or decimal value and there is some formatting pattern defined - // in the label (i.e. it contains at least a %) + // in the label or state description (i.e. it contains at least a %) try { final Item item = getItem(itemName); @@ -348,13 +353,8 @@ private Switch createPlayerButtons() { // returned StateDescription. What is expected is the display of a value using the pattern // provided by the channel state description provider. stateDescription = item.getStateDescription(); - if (formatPattern == null && stateDescription != null && stateDescription.getPattern() != null) { - label = label + " [" + stateDescription.getPattern() + "]"; - } - String updatedPattern = getFormatPattern(label); - if (updatedPattern != null) { - formatPattern = updatedPattern; + if (formatPattern != null) { state = item.getState(); if (formatPattern.contains("%d")) { @@ -371,7 +371,7 @@ private Switch createPlayerButtons() { } } } catch (ItemNotFoundException e) { - logger.error("Cannot retrieve item '{}' for widget {}", itemName, w.eClass().getInstanceTypeName()); + logger.warn("Cannot retrieve item '{}' for widget {}", itemName, w.eClass().getInstanceTypeName()); } boolean considerTransform = false; @@ -455,7 +455,10 @@ private Switch createPlayerButtons() { } label = label.trim(); - label = label.substring(0, label.indexOf("[") + 1) + formatPattern + "]"; + int index = label.indexOf("["); + if (index >= 0) { + label = label.substring(0, index + 1) + formatPattern + "]"; + } } } @@ -463,7 +466,7 @@ private Switch createPlayerButtons() { } private QuantityType convertStateToWidgetUnit(QuantityType quantityState, Widget w) { - Unit widgetUnit = UnitUtils.parseUnit(getFormatPattern(w.getLabel())); + Unit widgetUnit = UnitUtils.parseUnit(getFormatPattern(w)); if (widgetUnit != null && !widgetUnit.equals(quantityState.getUnit())) { return Objects.requireNonNullElse(quantityState.toInvertibleUnit(widgetUnit), quantityState); } @@ -471,6 +474,56 @@ private QuantityType convertStateToWidgetUnit(QuantityType quantityState, return quantityState; } + @Override + public @Nullable String getFormatPattern(Widget w) { + String label = getLabelFromWidget(w); + String pattern = getFormatPattern(label); + String itemName = w.getItem(); + try { + Item item = null; + if (itemName != null && !itemName.isBlank()) { + item = getItem(itemName); + } + if (item != null && pattern == null) { + StateDescription stateDescription = item.getStateDescription(); + if (stateDescription != null) { + pattern = stateDescription.getPattern(); + } + } + + if (pattern == null) { + return null; + } + + // remove last part of pattern, after unit, if it exists, as this is not valid and creates problems with + // updates + if (item instanceof NumberItem numberItem && numberItem.getDimension() != null) { + Matcher m = FORMAT_PATTERN.matcher(pattern); + int matcherEnd = 0; + if (m.find() && m.group(1) == null) { + matcherEnd = m.end(); + } + String unit = pattern.substring(matcherEnd).trim(); + String postfix = ""; + int unitEnd = unit.indexOf(" "); + if (unitEnd > -1) { + postfix = unit.substring(unitEnd + 1).trim(); + unit = unit.substring(0, unitEnd); + } + if (!postfix.isBlank()) { + logger.warn( + "Item '{}' with unit, nothing allowed after unit in label pattern '{}', dropping postfix", + itemName, pattern); + } + pattern = pattern.substring(0, matcherEnd) + (!unit.isBlank() ? " " + unit : ""); + } + } catch (ItemNotFoundException e) { + logger.warn("Cannot retrieve item '{}' for widget {}", itemName, w.eClass().getInstanceTypeName()); + } + + return pattern; + } + private @Nullable String getFormatPattern(@Nullable String label) { if (label == null) { return null; @@ -608,7 +661,7 @@ private String transform(String label, boolean matchTransform, @Nullable String Item item = getItem(itemName); return convertState(w, item, item.getState()); } catch (ItemNotFoundException e) { - logger.error("Cannot retrieve item '{}' for widget {}", itemName, w.eClass().getInstanceTypeName()); + logger.warn("Cannot retrieve item '{}' for widget {}", itemName, w.eClass().getInstanceTypeName()); } } return UnDefType.UNDEF; @@ -627,8 +680,8 @@ private String transform(String label, boolean matchTransform, @Nullable String State returnState = null; State itemState = i.getState(); - if (itemState instanceof QuantityType type) { - itemState = convertStateToWidgetUnit(type, w); + if (itemState instanceof QuantityType quantityTypeState) { + itemState = convertStateToWidgetUnit(quantityTypeState, w); } if (w instanceof Switch && i instanceof RollershutterItem) { @@ -637,7 +690,7 @@ private String transform(String label, boolean matchTransform, @Nullable String } else if (w instanceof Slider) { if (i.getAcceptedDataTypes().contains(PercentType.class)) { returnState = itemState.as(PercentType.class); - } else { + } else if (!(itemState instanceof QuantityType)) { returnState = itemState.as(DecimalType.class); } } else if (w instanceof Switch sw) { @@ -1208,7 +1261,7 @@ public boolean getVisiblity(Widget w) { try { item = itemRegistry.getItem(itemName); } catch (ItemNotFoundException e) { - logger.error("Cannot retrieve visibility item {} for widget {}", rule.getItem(), + logger.warn("Cannot retrieve visibility item {} for widget {}", rule.getItem(), w.eClass().getInstanceTypeName()); // Default to visible! @@ -1320,25 +1373,29 @@ public void removeRegistryHook(RegistryHook hook) { @Override public @Nullable String getUnitForWidget(Widget w) { - try { - Item item = getItem(w.getItem()); + String itemName = w.getItem(); + if (itemName != null) { + try { + Item item = getItem(itemName); - // we require the item to define a dimension, otherwise no unit will be reported to the UIs. - if (item instanceof NumberItem numberItem && numberItem.getDimension() != null) { - if (w.getLabel() == null) { - // if no Label was assigned to the Widget we fallback to the items unit - return numberItem.getUnitSymbol(); - } + // we require the item to define a dimension, otherwise no unit will be reported to the UIs. + if (item instanceof NumberItem numberItem && numberItem.getDimension() != null) { + String pattern = getFormatPattern(w); + if (pattern == null || pattern.isBlank()) { + // if no Label was assigned to the Widget we fallback to the items unit + return numberItem.getUnitSymbol(); + } - String unit = getUnitFromLabel(w.getLabel()); - if (!UnitUtils.UNIT_PLACEHOLDER.equals(unit)) { - return unit; - } + String unit = getUnitFromPattern(pattern); + if (!UnitUtils.UNIT_PLACEHOLDER.equals(unit)) { + return unit; + } - return numberItem.getUnitSymbol(); + return numberItem.getUnitSymbol(); + } + } catch (ItemNotFoundException e) { + logger.warn("Failed to retrieve item during widget rendering, item does not exist: {}", e.getMessage()); } - } catch (ItemNotFoundException e) { - logger.debug("Failed to retrieve item during widget rendering: {}", e.getMessage()); } return ""; @@ -1354,14 +1411,13 @@ public void removeRegistryHook(RegistryHook hook) { return state; } - private @Nullable String getUnitFromLabel(@Nullable String label) { - if (label == null || label.isBlank()) { + private @Nullable String getUnitFromPattern(@Nullable String format) { + if (format == null || format.isBlank()) { return null; } - Matcher m = LABEL_PATTERN.matcher(label); - if (m.matches()) { - return m.group(1); - } - return null; + int index = format.lastIndexOf(" "); + String unit = index > 0 ? format.substring(index + 1) : null; + unit = UnitUtils.UNIT_PERCENT_FORMAT_STRING.equals(unit) ? "%" : unit; + return unit; } } diff --git a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/items/ItemUIRegistry.java b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/items/ItemUIRegistry.java index 998a11d3c4c..a979543d4cf 100644 --- a/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/items/ItemUIRegistry.java +++ b/bundles/org.openhab.core.ui/src/main/java/org/openhab/core/ui/items/ItemUIRegistry.java @@ -34,6 +34,7 @@ * @author Kai Kreuzer - Initial contribution * @author Chris Jackson - Initial contribution * @author Laurent Garnier - new method getIconColor + * @author Mark Herwege - new method getFormatPattern */ @NonNullByDefault public interface ItemUIRegistry extends ItemRegistry, ItemUIProvider { @@ -130,6 +131,15 @@ public interface ItemUIRegistry extends ItemRegistry, ItemUIProvider { @Nullable EObject getParent(Widget w); + /** + * Gets the format pattern for the widget value, retrieved from widget label, item label or item state description + * + * @param w Widget + * @return String with the format pattern + */ + @Nullable + String getFormatPattern(Widget w); + /** * Gets the label color for the widget. Checks conditional statements to * find the color based on the item value diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java index 86ba0b8c04e..d34aa774b13 100644 --- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java +++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/VoiceManager.java @@ -135,6 +135,11 @@ public interface VoiceManager { @Nullable DialogContext getLastDialogContext(); + /** + * Returns a list with the contexts of all running dialogs. + */ + List getDialogsContexts(); + /** * Starts an infinite dialog sequence: keyword spotting on the audio source, audio source listening to retrieve * a question or a command (Speech to Text service), interpretation and handling of the command, and finally diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java index 2760edbd98e..f23b962140f 100644 --- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java +++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/DialogProcessor.java @@ -223,6 +223,13 @@ public void stop() { eventListener.onDialogStopped(dialogContext); } + /** + * Returns the dialog context used to start this processor. + */ + public DialogContext getContext() { + return dialogContext; + } + /** * Indicates if voice recognition is running. */ diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java index c29b0091b57..c4eb1ff0941 100644 --- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java +++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceConsoleCommandExtension.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -33,6 +34,7 @@ import org.openhab.core.items.ItemNotUniqueException; import org.openhab.core.items.ItemRegistry; import org.openhab.core.voice.DialogContext; +import org.openhab.core.voice.DialogRegistration; import org.openhab.core.voice.KSService; import org.openhab.core.voice.STTService; import org.openhab.core.voice.TTSService; @@ -60,7 +62,11 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio private static final String SUBCMD_VOICES = "voices"; private static final String SUBCMD_START_DIALOG = "startdialog"; private static final String SUBCMD_STOP_DIALOG = "stopdialog"; + private static final String SUBCMD_REGISTER_DIALOG = "registerdialog"; + private static final String SUBCMD_UNREGISTER_DIALOG = "unregisterdialog"; private static final String SUBCMD_LISTEN_ANSWER = "listenandanswer"; + private static final String SUBCMD_DIALOGS = "dialogs"; + private static final String SUBCMD_DIALOG_REGS = "dialogregs"; private static final String SUBCMD_INTERPRETERS = "interpreters"; private static final String SUBCMD_KEYWORD_SPOTTERS = "keywordspotters"; private static final String SUBCMD_STT_SERVICES = "sttservices"; @@ -87,13 +93,21 @@ public List getUsages() { return List.of(buildCommandUsage(SUBCMD_SAY + " ", "speaks a text"), buildCommandUsage(SUBCMD_INTERPRET + " ", "interprets a human language command"), buildCommandUsage(SUBCMD_VOICES, "lists available voices of the TTS services"), + buildCommandUsage(SUBCMD_DIALOGS, "lists the running dialog and their audio/voice services"), + buildCommandUsage(SUBCMD_DIALOG_REGS, + "lists the existing dialog registrations and their selected audio/voice services"), + buildCommandUsage(SUBCMD_REGISTER_DIALOG + + " [--source ] [--sink ] [--hlis ] [--tts [--voice ]] [--stt ] [--ks ks [--keyword ]] [--listening-item ]", + "register a new dialog processing using the default services or the services identified with provided arguments, it will be persisted and keep running whenever is possible."), + buildCommandUsage(SUBCMD_UNREGISTER_DIALOG + " [source]", + "unregister the dialog processing for the default audio source or the audio source identified with provided argument, stopping it if started"), buildCommandUsage(SUBCMD_START_DIALOG - + " [--source ] [--sink ] [--interpreters ] [--tts [--voice ]] [--stt ] [--ks ks [--keyword ]] [--listening-item ]", + + " [--source ] [--sink ] [--hlis ] [--tts [--voice ]] [--stt ] [--ks ks [--keyword ]] [--listening-item ]", "start a new dialog processing using the default services or the services identified with provided arguments"), buildCommandUsage(SUBCMD_STOP_DIALOG + " []", "stop the dialog processing for the default audio source or the audio source identified with provided argument"), buildCommandUsage(SUBCMD_LISTEN_ANSWER - + " [--source ] [--sink ] [--interpreters ] [--tts [--voice ]] [--stt ] [--listening-item ]", + + " [--source ] [--sink ] [--hlis ] [--tts [--voice ]] [--stt ] [--listening-item ]", "Execute a simple dialog sequence without keyword spotting using the default services or the services identified with provided arguments"), buildCommandUsage(SUBCMD_INTERPRETERS, "lists the interpreters"), buildCommandUsage(SUBCMD_KEYWORD_SPOTTERS, "lists the keyword spotters"), @@ -135,10 +149,41 @@ public void execute(String[] args, Console console) { } return; } + case SUBCMD_REGISTER_DIALOG -> { + DialogRegistration dialogRegistration; + try { + dialogRegistration = parseDialogRegistration(args); + } catch (IllegalStateException e) { + console.println(Objects.requireNonNullElse(e.getMessage(), + "An error occurred while parsing the dialog options")); + break; + } + try { + voiceManager.registerDialog(dialogRegistration); + } catch (IllegalStateException e) { + console.println(Objects.requireNonNullElse(e.getMessage(), + "An error occurred while registering the dialog")); + } + return; + } + case SUBCMD_UNREGISTER_DIALOG -> { + try { + var sourceId = args.length < 2 ? audioManager.getSourceId() : args[1]; + if (sourceId == null) { + console.println("No source provided nor default source available"); + break; + } + voiceManager.unregisterDialog(sourceId); + } catch (IllegalStateException e) { + console.println(Objects.requireNonNullElse(e.getMessage(), + "An error occurred while stopping the dialog")); + } + return; + } case SUBCMD_START_DIALOG -> { DialogContext.Builder dialogContextBuilder; try { - dialogContextBuilder = parseDialogParameters(args); + dialogContextBuilder = parseDialogContext(args); } catch (IllegalStateException e) { console.println(Objects.requireNonNullElse(e.getMessage(), "An error occurred while parsing the dialog options")); @@ -164,7 +209,7 @@ public void execute(String[] args, Console console) { case SUBCMD_LISTEN_ANSWER -> { DialogContext.Builder dialogContextBuilder; try { - dialogContextBuilder = parseDialogParameters(args); + dialogContextBuilder = parseDialogContext(args); } catch (IllegalStateException e) { console.println(Objects.requireNonNullElse(e.getMessage(), "An error occurred while parsing the dialog options")); @@ -178,6 +223,14 @@ public void execute(String[] args, Console console) { } return; } + case SUBCMD_DIALOGS -> { + listDialogs(console); + return; + } + case SUBCMD_DIALOG_REGS -> { + listDialogRegistrations(console); + return; + } case SUBCMD_INTERPRETERS -> { listInterpreters(console); return; @@ -252,6 +305,42 @@ private void say(String[] args, Console console) { voiceManager.say(msg.toString()); } + private void listDialogRegistrations(Console console) { + Collection registrations = voiceManager.getDialogRegistrations(); + if (!registrations.isEmpty()) { + registrations.stream().sorted(comparing(dr -> dr.sourceId)).forEach(dr -> { + console.println( + String.format(" Source: %s - Sink: %s (STT: %s, TTS: %s, HLIs: %s, KS: %s, Keyword: %s)", + dr.sourceId, dr.sinkId, getOrDefault(dr.sttId), getOrDefault(dr.ttsId), + dr.hliIds.isEmpty() ? getOrDefault(null) : String.join("->", dr.hliIds), + getOrDefault(dr.ksId), getOrDefault(dr.keyword))); + }); + } else { + console.println("No dialog registrations."); + } + } + + private String getOrDefault(@Nullable String value) { + return value != null && !value.isBlank() ? value : "**Default**"; + } + + private void listDialogs(Console console) { + Collection dialogContexts = voiceManager.getDialogsContexts(); + if (!dialogContexts.isEmpty()) { + dialogContexts.stream().sorted(comparing(s -> s.source().getId())).forEach(c -> { + var ks = c.ks(); + String ksText = ks != null ? String.format(", KS: %s, Keyword: %s", ks.getId(), c.keyword()) : ""; + console.println( + String.format(" Source: %s - Sink: %s (STT: %s, TTS: %s, HLIs: %s%s)", c.source().getId(), + c.sink().getId(), c.stt().getId(), c.tts().getId(), c.hlis().stream() + .map(HumanLanguageInterpreter::getId).collect(Collectors.joining("->")), + ksText)); + }); + } else { + console.println("No running dialogs."); + } + } + private void listInterpreters(Console console) { Collection interpreters = voiceManager.getHLIs(); if (!interpreters.isEmpty()) { @@ -314,11 +403,7 @@ private void listTTSs(Console console) { .orElse(null); } - private DialogContext.Builder parseDialogParameters(String[] args) { - var dialogContextBuilder = voiceManager.getDialogContextBuilder(); - if (args.length < 2) { - return dialogContextBuilder; - } + private HashMap parseDialogParameters(String[] args) { var parameters = new HashMap(); for (int i = 1; i < args.length; i++) { var arg = args[i].trim(); @@ -333,6 +418,15 @@ private DialogContext.Builder parseDialogParameters(String[] args) { throw new IllegalStateException("Argument name should start by -- " + arg); } } + return parameters; + } + + private DialogContext.Builder parseDialogContext(String[] args) { + var dialogContextBuilder = voiceManager.getDialogContextBuilder(); + if (args.length < 2) { + return dialogContextBuilder; + } + var parameters = parseDialogParameters(args); String sourceId = parameters.remove("source"); if (sourceId != null) { var source = audioManager.getSource(sourceId); @@ -363,4 +457,40 @@ private DialogContext.Builder parseDialogParameters(String[] args) { } return dialogContextBuilder; } + + private DialogRegistration parseDialogRegistration(String[] args) { + var parameters = parseDialogParameters(args); + @Nullable + String sourceId = parameters.remove("source"); + if (sourceId == null) { + sourceId = audioManager.getSourceId(); + } + if (sourceId == null) { + throw new IllegalStateException("A source is required if the default is not configured"); + } + @Nullable + String sinkId = parameters.remove("sink"); + if (sinkId == null) { + sinkId = audioManager.getSinkId(); + } + if (sinkId == null) { + throw new IllegalStateException("A sink is required if the default is not configured"); + } + var dr = new DialogRegistration(sourceId, sinkId); + dr.ksId = parameters.remove("ks"); + dr.keyword = parameters.remove("keyword"); + dr.sttId = parameters.remove("stt"); + dr.ttsId = parameters.remove("tts"); + dr.voiceId = parameters.remove("voice"); + dr.listeningItem = parameters.remove("listening-item"); + String hliIds = parameters.remove("hlis"); + if (hliIds != null) { + dr.hliIds = Arrays.stream(hliIds.split(",")).map(String::trim).collect(Collectors.toList()); + } + if (!parameters.isEmpty()) { + throw new IllegalStateException( + "Argument " + parameters.keySet().stream().findAny().orElse("") + " is not supported"); + } + return dr; + } } diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java index de770481a69..c70a2d84c22 100644 --- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java +++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/VoiceManagerImpl.java @@ -495,6 +495,11 @@ public DialogContext.Builder getDialogContextBuilder() { .withListeningItem(listeningItem); } + @Override + public List getDialogsContexts() { + return dialogProcessors.values().stream().map(DialogProcessor::getContext).collect(Collectors.toList()); + } + @Override public @Nullable DialogContext getLastDialogContext() { return lastDialogContext; diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/cache/lru/InputStreamCacheWrapper.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/cache/lru/InputStreamCacheWrapper.java index 1797e028db3..5cb13d60ffd 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/cache/lru/InputStreamCacheWrapper.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/cache/lru/InputStreamCacheWrapper.java @@ -37,6 +37,7 @@ public class InputStreamCacheWrapper extends InputStream { private LRUMediaCacheEntry cacheEntry; private int offset = 0; + private int markedOffset = 0; /*** * Construct a transparent InputStream wrapper around data from the cache. @@ -113,4 +114,19 @@ public long length() { public InputStream getClonedStream() throws IOException { return cacheEntry.getInputStream(); } + + @Override + public synchronized void mark(int readlimit) { + markedOffset = offset; + } + + @Override + public synchronized void reset() throws IOException { + offset = markedOffset; + } + + @Override + public boolean markSupported() { + return true; + } } diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/events/EventHandler.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/events/EventHandler.java index 4217bec0a24..aa8cbd624e3 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/events/EventHandler.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/events/EventHandler.java @@ -13,14 +13,18 @@ package org.openhab.core.internal.events; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -47,9 +51,7 @@ public class EventHandler implements AutoCloseable { private final Map> typedEventSubscribers; private final Map typedEventFactories; - private final ScheduledExecutorService watcher = Executors - .newSingleThreadScheduledExecutor(new NamedThreadFactory("eventwatcher")); - private final ExecutorService executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("eventexecutor")); + private final Map, ExecutorRecord> executors = new HashMap<>(); /** * Create a new event handler. @@ -63,10 +65,19 @@ public EventHandler(final Map> typedEventSubscriber this.typedEventFactories = typedEventFactories; } + private synchronized ExecutorRecord createExecutorRecord(Class subscriber) { + return new ExecutorRecord( + Executors.newSingleThreadExecutor(new NamedThreadFactory("eventexecutor-" + executors.size())), + Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("eventwatcher-" + executors.size())), + new AtomicInteger()); + } + @Override public void close() { - watcher.shutdownNow(); - executor.shutdownNow(); + executors.values().forEach(r -> { + r.executor.shutdownNow(); + r.watcher.shutdownNow(); + }); } public void handleEvent(org.osgi.service.event.Event osgiEvent) { @@ -140,8 +151,16 @@ private synchronized void dispatchEvent(final Set eventSubscrib EventFilter filter = eventSubscriber.getEventFilter(); if (filter == null || filter.apply(event)) { logger.trace("Delegate event to subscriber ({}).", eventSubscriber.getClass()); - executor.submit(() -> { - ScheduledFuture logTimeout = watcher.schedule( + ExecutorRecord executorRecord = Objects.requireNonNull( + executors.computeIfAbsent(eventSubscriber.getClass(), this::createExecutorRecord)); + int queueSize = executorRecord.count().incrementAndGet(); + if (queueSize > 1000) { + logger.warn( + "The queue for a subscriber of type '{}' exceeds 1000 elements. System may be unstable.", + eventSubscriber.getClass()); + } + CompletableFuture.runAsync(() -> { + ScheduledFuture logTimeout = executorRecord.watcher().schedule( () -> logger.warn("Dispatching event to subscriber '{}' takes more than {}ms.", eventSubscriber, EVENTSUBSCRIBER_EVENTHANDLING_MAX_MS), EVENTSUBSCRIBER_EVENTHANDLING_MAX_MS, TimeUnit.MILLISECONDS); @@ -152,10 +171,13 @@ private synchronized void dispatchEvent(final Set eventSubscrib EventSubscriber.class.getName(), ex.getMessage(), ex); } logTimeout.cancel(false); - }); + }, executorRecord.executor()).thenRun(executorRecord.count::decrementAndGet); } else { logger.trace("Skip event subscriber ({}) because of its filter.", eventSubscriber.getClass()); } } } + + private record ExecutorRecord(ExecutorService executor, ScheduledExecutorService watcher, AtomicInteger count) { + } } diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/service/WatchServiceImpl.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/service/WatchServiceImpl.java index b9c093d9220..c77010e37d9 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/service/WatchServiceImpl.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/service/WatchServiceImpl.java @@ -215,6 +215,7 @@ public void unregisterListener(WatchEventListener watchEventListener) { @Override public void onEvent(@Nullable DirectoryChangeEvent directoryChangeEvent) throws IOException { + logger.trace("onEvent {}", directoryChangeEvent); if (directoryChangeEvent == null || directoryChangeEvent.isDirectory() || directoryChangeEvent.eventType() == DirectoryChangeEvent.EventType.OVERFLOW) { // exit early, we are neither interested in directory events nor in OVERFLOW events @@ -223,12 +224,6 @@ public void onEvent(@Nullable DirectoryChangeEvent directoryChangeEvent) throws Path path = directoryChangeEvent.path(); - if (directoryChangeEvent.eventType() != DirectoryChangeEvent.EventType.DELETE - && directoryChangeEvent.hash() == null) { - logger.warn("Detected invalid event (hash must not be null for CREATE/MODIFY): {}", directoryChangeEvent); - return; - } - synchronized (scheduledEvents) { ScheduledFuture future = scheduledEvents.remove(path); if (future != null && !future.isDone()) { @@ -259,9 +254,17 @@ private void notifyListeners(Path path) { hashCache.remove(lastElement.path()); doNotify(path, Kind.DELETE); } else if (firstElement.eventType() == DirectoryChangeEvent.EventType.CREATE) { + if (lastElement.hash() == null) { + logger.warn("Detected invalid event (hash must not be null for CREATE/MODIFY): {}", lastElement); + return; + } hashCache.put(lastElement.path(), lastElement.hash()); doNotify(path, Kind.CREATE); } else { + if (lastElement.hash() == null) { + logger.warn("Detected invalid event (hash must not be null for CREATE/MODIFY): {}", lastElement); + return; + } FileHash oldHash = hashCache.put(lastElement.path(), lastElement.hash()); if (!Objects.equals(oldHash, lastElement.hash())) { // only notify if hashes are different, otherwise the file content did not chnge diff --git a/features/karaf/openhab-tp/src/main/feature/feature.xml b/features/karaf/openhab-tp/src/main/feature/feature.xml index ea76ff655e0..3e38044c9ca 100644 --- a/features/karaf/openhab-tp/src/main/feature/feature.xml +++ b/features/karaf/openhab-tp/src/main/feature/feature.xml @@ -103,12 +103,12 @@ - openhab.tp;feature=asm;version=9.2.0 - mvn:org.ow2.asm/asm/9.2 - mvn:org.ow2.asm/asm-analysis/9.2 - mvn:org.ow2.asm/asm-commons/9.2 - mvn:org.ow2.asm/asm-util/9.2 - mvn:org.ow2.asm/asm-tree/9.2 + openhab.tp;feature=asm;version=9.4.0 + mvn:org.ow2.asm/asm/9.4 + mvn:org.ow2.asm/asm-analysis/9.4 + mvn:org.ow2.asm/asm-commons/9.4 + mvn:org.ow2.asm/asm-util/9.4 + mvn:org.ow2.asm/asm-tree/9.4 @@ -120,7 +120,7 @@ mvn:org.osgi/org.osgi.util.function/1.1.0 mvn:org.osgi/org.osgi.util.promise/1.1.1 mvn:org.osgi/org.osgi.service.jaxrs/1.0.0 - mvn:org.apache.aries.spifly/org.apache.aries.spifly.dynamic.bundle/1.3.4 + mvn:org.apache.aries.spifly/org.apache.aries.spifly.dynamic.bundle/1.3.6 mvn:org.apache.aries.component-dsl/org.apache.aries.component-dsl.component-dsl/1.2.2 mvn:org.apache.aries.jax.rs/org.apache.aries.jax.rs.whiteboard/2.0.0 diff --git a/itests/org.openhab.core.automation.integration.tests/itest.bndrun b/itests/org.openhab.core.automation.integration.tests/itest.bndrun index 21aafaab4ef..9323415d07c 100644 --- a/itests/org.openhab.core.automation.integration.tests/itest.bndrun +++ b/itests/org.openhab.core.automation.integration.tests/itest.bndrun @@ -66,4 +66,5 @@ Fragment-Host: org.openhab.core.automation net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ org.objenesis;version='[3.3.0,3.3.1)',\ - org.osgi.service.cm;version='[1.6.0,1.6.1)' + org.osgi.service.cm;version='[1.6.0,1.6.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)' diff --git a/itests/org.openhab.core.automation.module.core.tests/itest.bndrun b/itests/org.openhab.core.automation.module.core.tests/itest.bndrun index de798e868f5..f87b5a71009 100644 --- a/itests/org.openhab.core.automation.module.core.tests/itest.bndrun +++ b/itests/org.openhab.core.automation.module.core.tests/itest.bndrun @@ -66,4 +66,5 @@ Fragment-Host: org.openhab.core.automation net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ org.objenesis;version='[3.3.0,3.3.1)',\ - org.osgi.service.cm;version='[1.6.0,1.6.1)' + org.osgi.service.cm;version='[1.6.0,1.6.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)' diff --git a/itests/org.openhab.core.automation.module.timer.tests/itest.bndrun b/itests/org.openhab.core.automation.module.timer.tests/itest.bndrun index 364e3f2dcac..5e6f415c499 100644 --- a/itests/org.openhab.core.automation.module.timer.tests/itest.bndrun +++ b/itests/org.openhab.core.automation.module.timer.tests/itest.bndrun @@ -66,4 +66,5 @@ Fragment-Host: org.openhab.core.automation net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ org.objenesis;version='[3.3.0,3.3.1)',\ - org.osgi.service.cm;version='[1.6.0,1.6.1)' + org.osgi.service.cm;version='[1.6.0,1.6.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)' diff --git a/itests/org.openhab.core.automation.tests/itest.bndrun b/itests/org.openhab.core.automation.tests/itest.bndrun index 55ed88082d3..98fb146d9a8 100644 --- a/itests/org.openhab.core.automation.tests/itest.bndrun +++ b/itests/org.openhab.core.automation.tests/itest.bndrun @@ -66,4 +66,5 @@ Fragment-Host: org.openhab.core.automation net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ org.objenesis;version='[3.3.0,3.3.1)',\ - org.osgi.service.cm;version='[1.6.0,1.6.1)' + org.osgi.service.cm;version='[1.6.0,1.6.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)' diff --git a/itests/org.openhab.core.config.discovery.mdns.tests/itest.bndrun b/itests/org.openhab.core.config.discovery.mdns.tests/itest.bndrun index 1c4f83e7614..b283f7cac4f 100644 --- a/itests/org.openhab.core.config.discovery.mdns.tests/itest.bndrun +++ b/itests/org.openhab.core.config.discovery.mdns.tests/itest.bndrun @@ -65,4 +65,6 @@ Fragment-Host: org.openhab.core.config.discovery.mdns net.bytebuddy.byte-buddy;version='[1.12.19,1.12.20)',\ net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ - org.objenesis;version='[3.3.0,3.3.1)' + org.objenesis;version='[3.3.0,3.3.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)',\ + org.osgi.service.cm;version='[1.6.0,1.6.1)' diff --git a/itests/org.openhab.core.config.discovery.tests/itest.bndrun b/itests/org.openhab.core.config.discovery.tests/itest.bndrun index 2f70c26108f..ad2ab040a73 100644 --- a/itests/org.openhab.core.config.discovery.tests/itest.bndrun +++ b/itests/org.openhab.core.config.discovery.tests/itest.bndrun @@ -64,4 +64,6 @@ Fragment-Host: org.openhab.core.config.discovery net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.junit-jupiter;version='[4.11.0,4.11.1)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ - org.objenesis;version='[3.3.0,3.3.1)' + org.objenesis;version='[3.3.0,3.3.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)',\ + org.osgi.service.cm;version='[1.6.0,1.6.1)' diff --git a/itests/org.openhab.core.config.discovery.usbserial.linuxsysfs.tests/itest.bndrun b/itests/org.openhab.core.config.discovery.usbserial.linuxsysfs.tests/itest.bndrun index 402acf7ea78..5f27c622288 100644 --- a/itests/org.openhab.core.config.discovery.usbserial.linuxsysfs.tests/itest.bndrun +++ b/itests/org.openhab.core.config.discovery.usbserial.linuxsysfs.tests/itest.bndrun @@ -65,4 +65,6 @@ Fragment-Host: org.openhab.core.config.discovery.usbserial.linuxsysfs net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.junit-jupiter;version='[4.11.0,4.11.1)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ - org.objenesis;version='[3.3.0,3.3.1)' + org.objenesis;version='[3.3.0,3.3.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)',\ + org.osgi.service.cm;version='[1.6.0,1.6.1)' diff --git a/itests/org.openhab.core.config.discovery.usbserial.tests/itest.bndrun b/itests/org.openhab.core.config.discovery.usbserial.tests/itest.bndrun index 956ee342b1a..20edb85c9d7 100644 --- a/itests/org.openhab.core.config.discovery.usbserial.tests/itest.bndrun +++ b/itests/org.openhab.core.config.discovery.usbserial.tests/itest.bndrun @@ -73,4 +73,6 @@ Provide-Capability: \ net.bytebuddy.byte-buddy;version='[1.12.19,1.12.20)',\ net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ - org.objenesis;version='[3.3.0,3.3.1)' + org.objenesis;version='[3.3.0,3.3.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)',\ + org.osgi.service.cm;version='[1.6.0,1.6.1)' diff --git a/itests/org.openhab.core.io.net.tests/itest.bndrun b/itests/org.openhab.core.io.net.tests/itest.bndrun index 0bd9c43c5e3..9d4983c500e 100644 --- a/itests/org.openhab.core.io.net.tests/itest.bndrun +++ b/itests/org.openhab.core.io.net.tests/itest.bndrun @@ -67,10 +67,10 @@ Fragment-Host: org.openhab.core.io.net uom-lib-common;version='[2.1.0,2.1.1)',\ org.eclipse.jetty.http2.server;version='[9.4.50,9.4.51)',\ org.eclipse.jetty.websocket.servlet;version='[9.4.50,9.4.51)',\ - org.apache.aries.spifly.dynamic.bundle;version='[1.3.4,1.3.5)',\ org.eclipse.jetty.websocket.server;version='[9.4.50,9.4.51)',\ org.objectweb.asm;version='[9.4.0,9.4.1)',\ - org.objectweb.asm.commons;version='[9.2.0,9.2.1)',\ - org.objectweb.asm.tree;version='[9.2.0,9.2.1)',\ - org.objectweb.asm.tree.analysis;version='[9.2.0,9.2.1)',\ - org.objectweb.asm.util;version='[9.2.0,9.2.1)' + org.apache.aries.spifly.dynamic.bundle;version='[1.3.6,1.3.7)',\ + org.objectweb.asm.commons;version='[9.4.0,9.4.1)',\ + org.objectweb.asm.tree;version='[9.4.0,9.4.1)',\ + org.objectweb.asm.tree.analysis;version='[9.4.0,9.4.1)',\ + org.objectweb.asm.util;version='[9.4.0,9.4.1)' diff --git a/itests/org.openhab.core.io.rest.core.tests/itest.bndrun b/itests/org.openhab.core.io.rest.core.tests/itest.bndrun index 5104bea137c..d839db2d30c 100644 --- a/itests/org.openhab.core.io.rest.core.tests/itest.bndrun +++ b/itests/org.openhab.core.io.rest.core.tests/itest.bndrun @@ -71,7 +71,6 @@ Fragment-Host: org.openhab.core.io.rest.core io.methvin.directory-watcher;version='[0.17.1,0.17.2)',\ com.sun.jna;version='[5.12.1,5.12.2)',\ com.fasterxml.woodstox.woodstox-core;version='[6.4.0,6.4.1)',\ - org.apache.aries.spifly.dynamic.bundle;version='[1.3.4,1.3.5)',\ org.apache.felix.configadmin;version='[1.9.26,1.9.27)',\ org.apache.felix.http.servlet-api;version='[1.2.0,1.2.1)',\ org.apache.felix.scr;version='[2.2.4,2.2.5)',\ @@ -84,10 +83,6 @@ Fragment-Host: org.openhab.core.io.rest.core org.eclipse.jetty.util;version='[9.4.50,9.4.51)',\ org.eclipse.jetty.util.ajax;version='[9.4.50,9.4.51)',\ org.eclipse.jetty.xml;version='[9.4.50,9.4.51)',\ - org.objectweb.asm.commons;version='[9.2.0,9.2.1)',\ - org.objectweb.asm.tree;version='[9.2.0,9.2.1)',\ - org.objectweb.asm.tree.analysis;version='[9.2.0,9.2.1)',\ - org.objectweb.asm.util;version='[9.2.0,9.2.1)',\ org.openhab.core.addon;version='[4.0.0,4.0.1)',\ org.ops4j.pax.logging.pax-logging-api;version='[2.2.0,2.2.1)',\ org.ops4j.pax.web.pax-web-api;version='[8.0.15,8.0.16)',\ @@ -107,4 +102,10 @@ Fragment-Host: org.openhab.core.io.rest.core net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.junit-jupiter;version='[4.11.0,4.11.1)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ - org.objenesis;version='[3.3.0,3.3.1)' + org.objenesis;version='[3.3.0,3.3.1)',\ + org.apache.aries.spifly.dynamic.bundle;version='[1.3.6,1.3.7)',\ + org.objectweb.asm.commons;version='[9.4.0,9.4.1)',\ + org.objectweb.asm.tree;version='[9.4.0,9.4.1)',\ + org.objectweb.asm.tree.analysis;version='[9.4.0,9.4.1)',\ + org.objectweb.asm.util;version='[9.4.0,9.4.1)',\ + org.osgi.service.cm;version='[1.6.0,1.6.1)' diff --git a/itests/org.openhab.core.storage.json.tests/itest.bndrun b/itests/org.openhab.core.storage.json.tests/itest.bndrun index 1f52ea404a7..d029def728f 100644 --- a/itests/org.openhab.core.storage.json.tests/itest.bndrun +++ b/itests/org.openhab.core.storage.json.tests/itest.bndrun @@ -58,4 +58,6 @@ Fragment-Host: org.openhab.core.storage.json junit-jupiter-engine;version='[5.9.2,5.9.3)',\ junit-platform-commons;version='[1.9.2,1.9.3)',\ junit-platform-engine;version='[1.9.2,1.9.3)',\ - junit-platform-launcher;version='[1.9.2,1.9.3)' + junit-platform-launcher;version='[1.9.2,1.9.3)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)',\ + org.osgi.service.cm;version='[1.6.0,1.6.1)' diff --git a/itests/org.openhab.core.thing.tests/itest.bndrun b/itests/org.openhab.core.thing.tests/itest.bndrun index 93bb6e00233..dc229ee0af3 100644 --- a/itests/org.openhab.core.thing.tests/itest.bndrun +++ b/itests/org.openhab.core.thing.tests/itest.bndrun @@ -66,4 +66,5 @@ Fragment-Host: org.openhab.core.thing net.bytebuddy.byte-buddy-agent;version='[1.12.19,1.12.20)',\ org.mockito.junit-jupiter;version='[4.11.0,4.11.1)',\ org.mockito.mockito-core;version='[4.11.0,4.11.1)',\ - org.objenesis;version='[3.3.0,3.3.1)' + org.objenesis;version='[3.3.0,3.3.1)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)' diff --git a/itests/org.openhab.core.voice.tests/itest.bndrun b/itests/org.openhab.core.voice.tests/itest.bndrun index b4e85f066ff..5d0738bd7d7 100644 --- a/itests/org.openhab.core.voice.tests/itest.bndrun +++ b/itests/org.openhab.core.voice.tests/itest.bndrun @@ -72,4 +72,5 @@ Fragment-Host: org.openhab.core.voice junit-jupiter-params;version='[5.9.2,5.9.3)',\ junit-platform-commons;version='[1.9.2,1.9.3)',\ junit-platform-engine;version='[1.9.2,1.9.3)',\ - junit-platform-launcher;version='[1.9.2,1.9.3)' + junit-platform-launcher;version='[1.9.2,1.9.3)',\ + org.openhab.core.transform;version='[4.0.0,4.0.1)'