- statesMap) {
}
return null;
}
-
- // static public JobKey timer(AbstractInstant instant, Object)
}
diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/ModifiablePersistenceService.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/ModifiablePersistenceService.java
index 2feb4f4660f..920fde8d8fb 100644
--- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/ModifiablePersistenceService.java
+++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/ModifiablePersistenceService.java
@@ -15,6 +15,7 @@
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.types.State;
@@ -46,6 +47,25 @@ public interface ModifiablePersistenceService extends QueryablePersistenceServic
*/
void store(Item item, ZonedDateTime date, State state);
+ /**
+ *
+ * Stores the historic item value under a specified alias. This allows the item, time and value to be specified.
+ *
+ *
+ * Adding data with the same time as an existing record should update the current record value rather than adding a
+ * new record.
+ *
+ *
+ * Implementors should keep in mind that all registered {@link PersistenceService}s are called synchronously. Hence
+ * long running operations should be processed asynchronously. E.g. store
adds things to a queue which
+ * is processed by some asynchronous workers (Quartz Job, Thread, etc.).
+ *
+ * @param item the data to be stored
+ * @param date the date of the record
+ * @param state the state to be recorded
+ */
+ void store(Item item, ZonedDateTime date, State state, @Nullable String alias);
+
/**
* Removes data associated with an item from a persistence service.
* If all data is removed for the specified item, the persistence service should free any resources associated with
diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/internal/PersistenceManager.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/internal/PersistenceManager.java
index 040be881101..b34282fcfa1 100644
--- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/internal/PersistenceManager.java
+++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/internal/PersistenceManager.java
@@ -12,6 +12,14 @@
*/
package org.openhab.core.persistence.internal;
+import static org.openhab.core.persistence.FilterCriteria.Ordering.ASCENDING;
+import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.FORECAST;
+import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.RESTORE;
+import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.UPDATE;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.HashSet;
@@ -23,6 +31,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
@@ -37,8 +46,10 @@
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.ItemRegistryChangeListener;
import org.openhab.core.items.StateChangeListener;
+import org.openhab.core.items.TimeSeriesListener;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.HistoricItem;
+import org.openhab.core.persistence.ModifiablePersistenceService;
import org.openhab.core.persistence.PersistenceItemConfiguration;
import org.openhab.core.persistence.PersistenceService;
import org.openhab.core.persistence.QueryablePersistenceService;
@@ -53,12 +64,14 @@
import org.openhab.core.persistence.strategy.PersistenceStrategy;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
+import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.service.ReadyMarker;
import org.openhab.core.service.ReadyMarkerFilter;
import org.openhab.core.service.ReadyService;
import org.openhab.core.service.ReadyService.ReadyTracker;
import org.openhab.core.service.StartLevelService;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@@ -75,18 +88,19 @@
* @author Kai Kreuzer - Initial contribution
* @author Markus Rathgeb - Separation of persistence core and model, drop Quartz usage.
* @author Jan N. Klug - Refactored to use service configuration registry
+ * @author Jan N. Klug - Added time series support
*/
@Component(immediate = true)
@NonNullByDefault
public class PersistenceManager implements ItemRegistryChangeListener, StateChangeListener, ReadyTracker,
- PersistenceServiceConfigurationRegistryChangeListener {
-
+ PersistenceServiceConfigurationRegistryChangeListener, TimeSeriesListener {
private final Logger logger = LoggerFactory.getLogger(PersistenceManager.class);
private final ReadyMarker marker = new ReadyMarker("persistence", "restore");
// the scheduler used for timer events
- private final CronScheduler scheduler;
+ private final CronScheduler cronScheduler;
+ private final Scheduler scheduler;
private final ItemRegistry itemRegistry;
private final SafeCaller safeCaller;
private final ReadyService readyService;
@@ -97,9 +111,11 @@ public class PersistenceManager implements ItemRegistryChangeListener, StateChan
private final Map persistenceServiceContainers = new ConcurrentHashMap<>();
@Activate
- public PersistenceManager(final @Reference CronScheduler scheduler, final @Reference ItemRegistry itemRegistry,
- final @Reference SafeCaller safeCaller, final @Reference ReadyService readyService,
+ public PersistenceManager(final @Reference CronScheduler cronScheduler, final @Reference Scheduler scheduler,
+ final @Reference ItemRegistry itemRegistry, final @Reference SafeCaller safeCaller,
+ final @Reference ReadyService readyService,
final @Reference PersistenceServiceConfigurationRegistry persistenceServiceConfigurationRegistry) {
+ this.cronScheduler = cronScheduler;
this.scheduler = scheduler;
this.itemRegistry = itemRegistry;
this.safeCaller = safeCaller;
@@ -118,6 +134,7 @@ protected void deactivate() {
started = false;
persistenceServiceContainers.values().forEach(PersistenceServiceContainer::cancelPersistJobs);
+ persistenceServiceContainers.values().forEach(PersistenceServiceContainer::cancelForecastJobs);
// remove item state change listeners
itemRegistry.stream().filter(GenericItem.class::isInstance)
@@ -136,6 +153,7 @@ protected void addPersistenceService(PersistenceService persistenceService) {
if (oldContainer != null) { // cancel all jobs if the persistence service is set and an old configuration is
// already present
oldContainer.cancelPersistJobs();
+ oldContainer.cancelForecastJobs();
}
if (started) {
@@ -147,6 +165,7 @@ protected void removePersistenceService(PersistenceService persistenceService) {
PersistenceServiceContainer container = persistenceServiceContainers.remove(persistenceService.getId());
if (container != null) {
container.cancelPersistJobs();
+ container.cancelForecastJobs();
}
}
@@ -237,61 +256,8 @@ private Iterable- getAllItems(PersistenceItemConfiguration config) {
return items;
}
- /**
- * Handles the "restoreOnStartup" strategy for the item.
- * If the item state is still undefined when entering this method, all persistence configurations are checked,
- * if they have the "restoreOnStartup" strategy configured for the item. If so, the item state will be set
- * to its last persisted value.
- *
- * @param item the item to restore the state for
- */
- private void restoreItemStateIfNeeded(Item item) {
- // get the last persisted state from the persistence service if no state is yet set
- if (UnDefType.NULL.equals(item.getState()) && item instanceof GenericItem) {
- List matchingContainers = persistenceServiceContainers.values().stream() //
- .filter(container -> container.getPersistenceService() instanceof QueryablePersistenceService) //
- .filter(container -> container.getMatchingConfigurations(PersistenceStrategy.Globals.RESTORE)
- .anyMatch(itemConfig -> appliesToItem(itemConfig, item)))
- .toList();
-
- for (PersistenceServiceContainer container : matchingContainers) {
- QueryablePersistenceService queryService = (QueryablePersistenceService) container
- .getPersistenceService();
- FilterCriteria filter = new FilterCriteria().setItemName(item.getName()).setPageSize(1);
- Iterable result = safeCaller.create(queryService, QueryablePersistenceService.class)
- .onTimeout(() -> {
- logger.warn("Querying persistence service '{}' to restore '{}' takes more than {}ms.",
- queryService.getId(), item.getName(), SafeCaller.DEFAULT_TIMEOUT);
- })
- .onException(e -> logger.error(
- "Exception occurred while querying persistence service '{}' to restore '{}': {}",
- queryService.getId(), item.getName(), e.getMessage(), e))
- .build().query(filter);
- if (result == null) {
- // in case of an exception or timeout, the safe caller returns null
- continue;
- }
- Iterator it = result.iterator();
- if (it.hasNext()) {
- HistoricItem historicItem = it.next();
- GenericItem genericItem = (GenericItem) item;
- genericItem.removeStateChangeListener(this);
- genericItem.setState(historicItem.getState());
- genericItem.addStateChangeListener(this);
- if (logger.isDebugEnabled()) {
- logger.debug("Restored item state from '{}' for item '{}' -> '{}'",
- DateTimeFormatter.ISO_ZONED_DATE_TIME.format(historicItem.getTimestamp()),
- item.getName(), historicItem.getState());
- }
- return;
- }
- }
- }
- }
-
private void startEventHandling(PersistenceServiceContainer serviceContainer) {
- serviceContainer.getMatchingConfigurations(PersistenceStrategy.Globals.RESTORE)
- .forEach(itemConfig -> getAllItems(itemConfig).forEach(this::restoreItemStateIfNeeded));
+ serviceContainer.restoreStatesAndScheduleForecastJobs();
serviceContainer.schedulePersistJobs();
}
@@ -304,16 +270,19 @@ public void allItemsChanged(Collection oldItemNames) {
@Override
public void added(Item item) {
- restoreItemStateIfNeeded(item);
+ persistenceServiceContainers.values().forEach(container -> container.addItem(item));
if (item instanceof GenericItem genericItem) {
genericItem.addStateChangeListener(this);
+ genericItem.addTimeSeriesListener(this);
}
}
@Override
public void removed(Item item) {
+ persistenceServiceContainers.values().forEach(container -> container.removeItem(item.getName()));
if (item instanceof GenericItem genericItem) {
genericItem.removeStateChangeListener(this);
+ genericItem.removeTimeSeriesListener(this);
}
}
@@ -333,6 +302,50 @@ public void stateUpdated(Item item, State state) {
handleStateEvent(item, false);
}
+ @Override
+ public void timeSeriesUpdated(Item item, TimeSeries timeSeries) {
+ if (timeSeries.size() == 0) {
+ // discard empty time series
+ return;
+ }
+ persistenceServiceContainers.values().stream()
+ .filter(psc -> psc.persistenceService instanceof ModifiablePersistenceService)
+ .forEach(container -> Stream
+ .concat(container.getMatchingConfigurations(UPDATE),
+ container.getMatchingConfigurations(FORECAST))
+ .distinct().filter(itemConfig -> appliesToItem(itemConfig, item)).forEach(itemConfig -> {
+ ModifiablePersistenceService service = (ModifiablePersistenceService) container
+ .getPersistenceService();
+ // remove old values if replace selected
+ if (timeSeries.getPolicy() == TimeSeries.Policy.REPLACE) {
+ ZonedDateTime begin = timeSeries.getBegin().atZone(ZoneId.systemDefault());
+ ZonedDateTime end = timeSeries.getEnd().atZone(ZoneId.systemDefault());
+ FilterCriteria removeFilter = new FilterCriteria().setItemName(item.getName())
+ .setBeginDate(begin).setEndDate(end);
+ service.remove(removeFilter);
+ ScheduledCompletableFuture> forecastJob = container.forecastJobs.get(item.getName());
+ if (forecastJob != null && forecastJob.getScheduledTime().isAfter(begin)
+ && forecastJob.getScheduledTime().isBefore(end)) {
+ forecastJob.cancel(true);
+ container.forecastJobs.remove(item.getName());
+ }
+ }
+ // update states
+ timeSeries.getStates().forEach(
+ e -> service.store(item, e.timestamp().atZone(ZoneId.systemDefault()), e.state()));
+ timeSeries.getStates().filter(s -> s.timestamp().isAfter(Instant.now())).findFirst()
+ .ifPresent(s -> {
+ ScheduledCompletableFuture> forecastJob = container.forecastJobs
+ .get(item.getName());
+ if (forecastJob == null || forecastJob.getScheduledTime()
+ .isAfter(s.timestamp().atZone(ZoneId.systemDefault()))) {
+ container.scheduleNextForecastForItem(item.getName(), s.timestamp(),
+ s.state());
+ }
+ });
+ }));
+ }
+
@Override
public void onReadyMarkerAdded(ReadyMarker readyMarker) {
ExecutorService scheduler = Executors.newSingleThreadExecutor(new NamedThreadFactory("persistenceManager"));
@@ -381,7 +394,9 @@ public void updated(PersistenceServiceConfiguration oldElement, PersistenceServi
private class PersistenceServiceContainer {
private final PersistenceService persistenceService;
- private final Set> jobs = new HashSet<>();
+ private final Set> persistJobs = new HashSet<>();
+ private final Map> forecastJobs = new ConcurrentHashMap<>();
+ private final Map> strategyCache = new ConcurrentHashMap<>();
private PersistenceServiceConfiguration configuration;
@@ -403,19 +418,25 @@ public PersistenceService getPersistenceService() {
*/
public void setConfiguration(@Nullable PersistenceServiceConfiguration configuration) {
cancelPersistJobs();
+ cancelForecastJobs();
this.configuration = Objects.requireNonNullElseGet(configuration, this::getDefaultConfig);
+ strategyCache.clear();
}
/**
* Get all item configurations from this service that match a certain strategy
*
* @param strategy the {@link PersistenceStrategy} to look for
- * @return a @link Stream} of the result
+ * @return a {@link Stream} of the result
*/
public Stream getMatchingConfigurations(PersistenceStrategy strategy) {
- boolean matchesDefaultStrategies = configuration.getDefaults().contains(strategy);
- return configuration.getConfigs().stream().filter(itemConfig -> itemConfig.strategies().contains(strategy)
- || (itemConfig.strategies().isEmpty() && matchesDefaultStrategies));
+ return Objects.requireNonNull(strategyCache.computeIfAbsent(strategy, s -> {
+ boolean matchesDefaultStrategies = configuration.getDefaults().contains(strategy);
+ return configuration.getConfigs().stream()
+ .filter(itemConfig -> itemConfig.strategies().contains(strategy)
+ || (itemConfig.strategies().isEmpty() && matchesDefaultStrategies))
+ .toList();
+ }).stream());
}
private PersistenceServiceConfiguration getDefaultConfig() {
@@ -430,11 +451,19 @@ private PersistenceServiceConfiguration getDefaultConfig() {
* Cancel all scheduled cron jobs / strategies for this service
*/
public void cancelPersistJobs() {
- synchronized (jobs) {
- jobs.forEach(job -> job.cancel(true));
- jobs.clear();
+ synchronized (persistJobs) {
+ persistJobs.forEach(job -> job.cancel(true));
+ persistJobs.clear();
+ }
+ logger.debug("Removed scheduled cron jobs for persistence service '{}'", configuration.getUID());
+ }
+
+ public void cancelForecastJobs() {
+ synchronized (forecastJobs) {
+ forecastJobs.values().forEach(job -> job.cancel(true));
+ forecastJobs.clear();
}
- logger.debug("Removed scheduled cron job for persistence service '{}'", configuration.getUID());
+ logger.debug("Removed scheduled forecast jobs for persistence service '{}'", configuration.getUID());
}
/**
@@ -446,7 +475,7 @@ public void schedulePersistJobs() {
PersistenceCronStrategy cronStrategy = (PersistenceCronStrategy) strategy;
String cronExpression = cronStrategy.getCronExpression();
List itemConfigs = getMatchingConfigurations(strategy).toList();
- jobs.add(scheduler.schedule(() -> persistJob(itemConfigs), cronExpression));
+ persistJobs.add(cronScheduler.schedule(() -> persistJob(itemConfigs), cronExpression));
logger.debug("Scheduled strategy {} with cron expression {} for service {}",
cronStrategy.getName(), cronExpression, configuration.getUID());
@@ -454,6 +483,108 @@ public void schedulePersistJobs() {
});
}
+ public void restoreStatesAndScheduleForecastJobs() {
+ itemRegistry.getItems().forEach(this::addItem);
+ }
+
+ public void addItem(Item item) {
+ if (persistenceService instanceof QueryablePersistenceService) {
+ if (UnDefType.NULL.equals(item.getState())
+ && (getMatchingConfigurations(RESTORE)
+ .anyMatch(configuration -> appliesToItem(configuration, item)))
+ || getMatchingConfigurations(FORECAST)
+ .anyMatch(configuration -> appliesToItem(configuration, item))) {
+ restoreItemStateIfPossible(item);
+ }
+ if (getMatchingConfigurations(FORECAST).anyMatch(configuration -> appliesToItem(configuration, item))) {
+ scheduleNextPersistedForecastForItem(item.getName());
+
+ }
+ }
+ }
+
+ public void removeItem(String itemName) {
+ ScheduledCompletableFuture> job = forecastJobs.remove(itemName);
+ if (job != null) {
+ job.cancel(true);
+ }
+ }
+
+ private void restoreItemStateIfPossible(Item item) {
+ QueryablePersistenceService queryService = (QueryablePersistenceService) persistenceService;
+
+ FilterCriteria filter = new FilterCriteria().setItemName(item.getName()).setEndDate(ZonedDateTime.now())
+ .setPageSize(1);
+ Iterable result = safeCaller.create(queryService, QueryablePersistenceService.class)
+ .onTimeout(
+ () -> logger.warn("Querying persistence service '{}' to restore '{}' takes more than {}ms.",
+ queryService.getId(), item.getName(), SafeCaller.DEFAULT_TIMEOUT))
+ .onException(e -> logger.error(
+ "Exception occurred while querying persistence service '{}' to restore '{}': {}",
+ queryService.getId(), item.getName(), e.getMessage(), e))
+ .build().query(filter);
+ if (result == null) {
+ // in case of an exception or timeout, the safe caller returns null
+ return;
+ }
+ Iterator it = result.iterator();
+ if (it.hasNext()) {
+ HistoricItem historicItem = it.next();
+ GenericItem genericItem = (GenericItem) item;
+ if (!UnDefType.NULL.equals(item.getState())) {
+ // someone else already restored the state or a new state was set
+ return;
+ }
+ genericItem.removeStateChangeListener(PersistenceManager.this);
+ genericItem.setState(historicItem.getState());
+ genericItem.addStateChangeListener(PersistenceManager.this);
+ if (logger.isDebugEnabled()) {
+ logger.debug("Restored item state from '{}' for item '{}' -> '{}'",
+ DateTimeFormatter.ISO_ZONED_DATE_TIME.format(historicItem.getTimestamp()), item.getName(),
+ historicItem.getState());
+ }
+ }
+ }
+
+ public void scheduleNextForecastForItem(String itemName, Instant time, State state) {
+ ScheduledFuture> oldJob = forecastJobs.remove(itemName);
+ if (oldJob != null) {
+ oldJob.cancel(true);
+ }
+ forecastJobs.put(itemName, scheduler.at(() -> restoreItemState(itemName, state), time));
+ logger.trace("Scheduled forecasted value for {} at {}", itemName, time);
+ }
+
+ public void scheduleNextPersistedForecastForItem(String itemName) {
+ Item item = itemRegistry.get(itemName);
+ if (item instanceof GenericItem) {
+ QueryablePersistenceService queryService = (QueryablePersistenceService) persistenceService;
+ FilterCriteria filter = new FilterCriteria().setItemName(itemName).setBeginDate(ZonedDateTime.now())
+ .setOrdering(ASCENDING);
+ Iterator result = safeCaller.create(queryService, QueryablePersistenceService.class)
+ .onTimeout(() -> logger.warn("Querying persistence service '{}' takes more than {}ms.",
+ queryService.getId(), SafeCaller.DEFAULT_TIMEOUT))
+ .onException(e -> logger.error("Exception occurred while querying persistence service '{}': {}",
+ queryService.getId(), e.getMessage(), e))
+ .build().query(filter).iterator();
+ while (result.hasNext()) {
+ HistoricItem next = result.next();
+ if (next.getTimestamp().isAfter(ZonedDateTime.now())) {
+ scheduleNextForecastForItem(itemName, next.getTimestamp().toInstant(), next.getState());
+ break;
+ }
+ }
+ }
+ }
+
+ private void restoreItemState(String itemName, State state) {
+ Item item = itemRegistry.get(itemName);
+ if (item != null) {
+ ((GenericItem) item).setState(state);
+ }
+ scheduleNextPersistedForecastForItem(itemName);
+ }
+
private void persistJob(List itemConfigs) {
itemConfigs.forEach(itemConfig -> {
for (Item item : getAllItems(itemConfig)) {
diff --git a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/strategy/PersistenceStrategy.java b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/strategy/PersistenceStrategy.java
index 4cbf6ca659a..343615e8f8b 100644
--- a/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/strategy/PersistenceStrategy.java
+++ b/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/strategy/PersistenceStrategy.java
@@ -29,9 +29,12 @@ public static class Globals {
public static final PersistenceStrategy UPDATE = new PersistenceStrategy("everyUpdate");
public static final PersistenceStrategy CHANGE = new PersistenceStrategy("everyChange");
public static final PersistenceStrategy RESTORE = new PersistenceStrategy("restoreOnStartup");
-
- public static final Map STRATEGIES = Map.of(UPDATE.name, UPDATE, CHANGE.name,
- CHANGE, RESTORE.name, RESTORE);
+ public static final PersistenceStrategy FORECAST = new PersistenceStrategy("forecast");
+ public static final Map STRATEGIES = Map.of( //
+ UPDATE.name, UPDATE, //
+ CHANGE.name, CHANGE, //
+ RESTORE.name, RESTORE, //
+ FORECAST.name, FORECAST);
}
private final String name;
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 b33766fbd4a..59888597860 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
@@ -14,17 +14,20 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.clearInvocations;
-import static org.mockito.Mockito.timeout;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.when;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.ZoneId;
import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -32,6 +35,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
@@ -39,14 +43,18 @@
import org.openhab.core.common.SafeCaller;
import org.openhab.core.common.SafeCallerBuilder;
import org.openhab.core.items.GroupItem;
+import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.StringItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.StringType;
+import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.HistoricItem;
+import org.openhab.core.persistence.ModifiablePersistenceService;
import org.openhab.core.persistence.PersistenceItemConfiguration;
+import org.openhab.core.persistence.PersistenceItemInfo;
import org.openhab.core.persistence.PersistenceService;
import org.openhab.core.persistence.QueryablePersistenceService;
import org.openhab.core.persistence.config.PersistenceAllConfig;
@@ -61,10 +69,12 @@
import org.openhab.core.persistence.strategy.PersistenceStrategy;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
+import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.scheduler.SchedulerRunnable;
import org.openhab.core.service.ReadyMarker;
import org.openhab.core.service.ReadyService;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -108,7 +118,10 @@ public String getName() {
private static final String TEST_PERSISTENCE_SERVICE_ID = "testPersistenceService";
private static final String TEST_QUERYABLE_PERSISTENCE_SERVICE_ID = "testQueryablePersistenceService";
+ private static final String TEST_MODIFIABLE_PERSISTENCE_SERVICE_ID = "testModifiablePersistenceService";
+
private @NonNullByDefault({}) @Mock CronScheduler cronSchedulerMock;
+ private @NonNullByDefault({}) @Mock Scheduler schedulerMock;
private @NonNullByDefault({}) @Mock ScheduledCompletableFuture scheduledFutureMock;
private @NonNullByDefault({}) @Mock ItemRegistry itemRegistryMock;
private @NonNullByDefault({}) @Mock SafeCaller safeCallerMock;
@@ -118,6 +131,7 @@ public String getName() {
private @NonNullByDefault({}) @Mock PersistenceService persistenceServiceMock;
private @NonNullByDefault({}) @Mock QueryablePersistenceService queryablePersistenceServiceMock;
+ private @NonNullByDefault({}) @Mock ModifiablePersistenceService modifiablePersistenceServiceMock;
private @NonNullByDefault({}) PersistenceManager manager;
@@ -139,13 +153,15 @@ public void setUp() throws ItemNotFoundException {
when(persistenceServiceMock.getId()).thenReturn(TEST_PERSISTENCE_SERVICE_ID);
when(queryablePersistenceServiceMock.getId()).thenReturn(TEST_QUERYABLE_PERSISTENCE_SERVICE_ID);
when(queryablePersistenceServiceMock.query(any())).thenReturn(List.of(TEST_HISTORIC_ITEM));
+ when(modifiablePersistenceServiceMock.getId()).thenReturn(TEST_MODIFIABLE_PERSISTENCE_SERVICE_ID);
- manager = new PersistenceManager(cronSchedulerMock, itemRegistryMock, safeCallerMock, readyServiceMock,
- persistenceServiceConfigurationRegistryMock);
+ manager = new PersistenceManager(cronSchedulerMock, schedulerMock, itemRegistryMock, safeCallerMock,
+ readyServiceMock, persistenceServiceConfigurationRegistryMock);
manager.addPersistenceService(persistenceServiceMock);
manager.addPersistenceService(queryablePersistenceServiceMock);
+ manager.addPersistenceService(modifiablePersistenceServiceMock);
- clearInvocations(persistenceServiceMock, queryablePersistenceServiceMock);
+ clearInvocations(persistenceServiceMock, queryablePersistenceServiceMock, modifiablePersistenceServiceMock);
}
@Test
@@ -299,6 +315,82 @@ public void noRestoreOnStartupWhenItemNotNull() {
verifyNoMoreInteractions(persistenceServiceMock);
}
+ @Test
+ public void storeTimeSeriesAndForecastsScheduled() {
+ List> futures = new ArrayList<>();
+ TestModifiablePersistenceService service = spy(new TestModifiablePersistenceService());
+ manager.addPersistenceService(service);
+
+ when(schedulerMock.at(any(SchedulerRunnable.class), any(Instant.class))).thenAnswer(i -> {
+ ScheduledCompletableFuture> future = mock(ScheduledCompletableFuture.class);
+ when(future.getScheduledTime()).thenReturn(((Instant) i.getArgument(1)).atZone(ZoneId.systemDefault()));
+ futures.add(future);
+ return future;
+ });
+
+ addConfiguration(TestModifiablePersistenceService.ID, new PersistenceAllConfig(),
+ PersistenceStrategy.Globals.FORECAST, null);
+
+ Instant time1 = Instant.now().minusSeconds(1000);
+ Instant time2 = Instant.now().plusSeconds(1000);
+ Instant time3 = Instant.now().plusSeconds(2000);
+ Instant time4 = Instant.now().plusSeconds(3000);
+
+ // add elements
+ TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.ADD);
+ timeSeries.add(time1, new StringType("one"));
+ timeSeries.add(time2, new StringType("two"));
+ timeSeries.add(time3, new StringType("three"));
+ timeSeries.add(time4, new StringType("four"));
+
+ manager.timeSeriesUpdated(TEST_ITEM, timeSeries);
+ InOrder inOrder = inOrder(service, schedulerMock);
+
+ // verify elements are stored
+ timeSeries.getStates().forEach(entry -> inOrder.verify(service).store(any(Item.class),
+ eq(entry.timestamp().atZone(ZoneId.systemDefault())), eq(entry.state())));
+
+ // first element not scheduled, because it is in the past, check if second is scheduled
+ inOrder.verify(schedulerMock).at(any(SchedulerRunnable.class), eq(time2));
+ inOrder.verifyNoMoreInteractions();
+
+ // replace elements
+ TimeSeries timeSeries2 = new TimeSeries(TimeSeries.Policy.REPLACE);
+ timeSeries2.add(time3, new StringType("three2"));
+ timeSeries2.add(time4, new StringType("four2"));
+
+ manager.timeSeriesUpdated(TEST_ITEM, timeSeries2);
+
+ // verify removal of old elements from service
+ ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(FilterCriteria.class);
+ inOrder.verify(service).remove(filterCaptor.capture());
+ FilterCriteria filterCriteria = filterCaptor.getValue();
+ assertThat(filterCriteria.getItemName(), is(TEST_ITEM_NAME));
+ assertThat(filterCriteria.getBeginDate(), is(time3.atZone(ZoneId.systemDefault())));
+ assertThat(filterCriteria.getEndDate(), is(time4.atZone(ZoneId.systemDefault())));
+
+ // verify restore future is not cancelled
+ verify(futures.get(0), never()).cancel(anyBoolean());
+
+ // verify new values are stored
+ inOrder.verify(service, times(2)).store(any(Item.class), any(ZonedDateTime.class), any(State.class));
+ inOrder.verifyNoMoreInteractions();
+
+ // try adding a new element in front and check it is correctly scheduled
+ Instant time5 = Instant.now().plusSeconds(500);
+ // add elements
+ TimeSeries timeSeries3 = new TimeSeries(TimeSeries.Policy.ADD);
+ timeSeries3.add(time5, new StringType("five"));
+
+ manager.timeSeriesUpdated(TEST_ITEM, timeSeries3);
+ // verify old restore future is cancelled
+ inOrder.verify(service, times(1)).store(any(Item.class), any(ZonedDateTime.class), any(State.class));
+ verify(futures.get(0)).cancel(true);
+
+ // verify new restore future is properly created
+ inOrder.verify(schedulerMock).at(any(SchedulerRunnable.class), eq(time5));
+ }
+
@Test
public void cronStrategyIsScheduledAndCancelledAndPersistsValue() throws Exception {
ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(SchedulerRunnable.class);
@@ -402,4 +494,85 @@ private PersistenceServiceConfiguration addConfiguration(String serviceId, Persi
return serviceConfiguration;
}
+
+ private static class TestModifiablePersistenceService implements ModifiablePersistenceService {
+ public static final String ID = "TMPS";
+ private final Map states = new HashMap<>();
+
+ @Override
+ public void store(Item item, ZonedDateTime date, State state) {
+ states.put(date, state);
+ }
+
+ @Override
+ public void store(Item item, ZonedDateTime date, State state, @Nullable String alias) {
+ store(item, date, state);
+ }
+
+ @Override
+ public boolean remove(FilterCriteria filter) throws IllegalArgumentException {
+ ZonedDateTime begin = Objects.requireNonNull(filter.getBeginDate());
+ ZonedDateTime end = Objects.requireNonNull(filter.getEndDate());
+ List keys = states.keySet().stream().filter(t -> t.isAfter(begin) && t.isBefore(end))
+ .toList();
+ keys.forEach(states::remove);
+ return !keys.isEmpty();
+ }
+
+ @Override
+ public String getId() {
+ return ID;
+ }
+
+ @Override
+ public String getLabel(@Nullable Locale locale) {
+ return ID;
+ }
+
+ @Override
+ public void store(Item item) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void store(Item item, @Nullable String alias) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List getDefaultStrategies() {
+ return List.of();
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Iterable query(FilterCriteria filter) {
+ ZonedDateTime begin = Objects.requireNonNull(filter.getBeginDate());
+ ZonedDateTime end = Objects.requireNonNull(filter.getEndDate());
+ List keys = states.keySet().stream().filter(t -> t.isAfter(begin) && t.isBefore(end))
+ .toList();
+ return (Iterable) states.entrySet().stream().filter(e -> keys.contains(e.getKey()))
+ .map(e -> new HistoricItem() {
+ @Override
+ public ZonedDateTime getTimestamp() {
+ return e.getKey();
+ }
+
+ @Override
+ public State getState() {
+ return e.getValue();
+ }
+
+ @Override
+ public String getName() {
+ return "item";
+ }
+ }).iterator();
+ }
+
+ @Override
+ public Set getItemInfo() {
+ return Set.of();
+ }
+ }
}
diff --git a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/MagicBindingConstants.java b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/MagicBindingConstants.java
index 166f29c2f93..47d1981b099 100644
--- a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/MagicBindingConstants.java
+++ b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/MagicBindingConstants.java
@@ -48,6 +48,7 @@ public class MagicBindingConstants {
public static final ThingTypeUID THING_TYPE_DYNAMIC_STATE_DESCRIPTION = new ThingTypeUID(BINDING_ID,
"dynamic-state-description");
public static final ThingTypeUID THING_TYPE_ONLINE_OFFLINE = new ThingTypeUID(BINDING_ID, "online-offline");
+ public static final ThingTypeUID THING_TYPE_TIMESERIES = new ThingTypeUID(BINDING_ID, "timeseries");
// bridged things
public static final ThingTypeUID THING_TYPE_BRIDGE_1 = new ThingTypeUID(BINDING_ID, "magic-bridge1");
@@ -67,7 +68,7 @@ public class MagicBindingConstants {
public static final String CHANNEL_BATTERY_LEVEL = "battery-level";
public static final String CHANNEL_SYSTEM_COMMAND = "systemcommand";
public static final String CHANNEL_SIGNAL_STRENGTH = "signal-strength";
-
+ public static final String CHANNEL_FORECAST = "forecast";
// Firmware update needed models
public static final String UPDATE_MODEL_PROPERTY = "updateModel";
diff --git a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/handler/MagicTimeSeriesHandler.java b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/handler/MagicTimeSeriesHandler.java
new file mode 100644
index 00000000000..a596a7719e2
--- /dev/null
+++ b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/handler/MagicTimeSeriesHandler.java
@@ -0,0 +1,111 @@
+/**
+ * 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.magic.binding.handler;
+
+import static org.openhab.core.magic.binding.MagicBindingConstants.CHANNEL_FORECAST;
+import static org.openhab.core.types.TimeSeries.Policy.ADD;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link MagicTimeSeriesHandler} is capable of providing a series of different forecasts
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class MagicTimeSeriesHandler extends BaseThingHandler {
+
+ private @Nullable ScheduledFuture> scheduledJob;
+ private Configuration configuration = new Configuration();
+
+ public MagicTimeSeriesHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ // no-op
+ }
+
+ @Override
+ public void initialize() {
+ configuration = getConfigAs(Configuration.class);
+ startScheduledJob();
+
+ updateStatus(ThingStatus.ONLINE);
+ }
+
+ @Override
+ public void dispose() {
+ stopScheduledJob();
+ }
+
+ private void startScheduledJob() {
+ ScheduledFuture> localScheduledJob = scheduledJob;
+ if (localScheduledJob == null || localScheduledJob.isCancelled()) {
+ scheduledJob = scheduler.scheduleWithFixedDelay(() -> {
+ Instant now = Instant.now();
+ TimeSeries timeSeries = new TimeSeries(ADD);
+ Duration stepSize = Duration.ofSeconds(configuration.interval / configuration.count);
+ double range = configuration.max - configuration.min;
+ for (int i = 1; i <= configuration.count; i++) {
+ double value = switch (configuration.type) {
+ case RND -> Math.random() * range + configuration.min;
+ case ASC -> (range / configuration.count) * i + configuration.min;
+ case DESC -> configuration.max + (range / configuration.count) * i;
+ };
+ timeSeries.add(now.plus(stepSize.multipliedBy(i)), new DecimalType(value));
+ }
+ sendTimeSeries(CHANNEL_FORECAST, timeSeries);
+ }, 0, configuration.interval, TimeUnit.SECONDS);
+ }
+ }
+
+ private void stopScheduledJob() {
+ ScheduledFuture> localScheduledJob = scheduledJob;
+ if (localScheduledJob != null && !localScheduledJob.isCancelled()) {
+ localScheduledJob.cancel(true);
+ scheduledJob = null;
+ }
+ }
+
+ public static class Configuration {
+ public int interval = 600;
+ public Type type = Type.RND;
+ public double min = 0.0;
+ public double max = 100.0;
+ public int count = 10;
+
+ public Configuration() {
+ }
+ }
+
+ public enum Type {
+ RND,
+ ASC,
+ DESC
+ }
+}
diff --git a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicHandlerFactory.java b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicHandlerFactory.java
index 5cfe5b7e286..b2ae4f9464d 100644
--- a/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicHandlerFactory.java
+++ b/bundles/org.openhab.core.test.magic/src/main/java/org/openhab/core/magic/binding/internal/MagicHandlerFactory.java
@@ -38,6 +38,7 @@
import org.openhab.core.magic.binding.handler.MagicPlayerHandler;
import org.openhab.core.magic.binding.handler.MagicRollershutterHandler;
import org.openhab.core.magic.binding.handler.MagicThermostatThingHandler;
+import org.openhab.core.magic.binding.handler.MagicTimeSeriesHandler;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
@@ -62,8 +63,8 @@ public class MagicHandlerFactory extends BaseThingHandlerFactory {
THING_TYPE_CONTACT_SENSOR, THING_TYPE_CONFIG_THING, THING_TYPE_DELAYED_THING, THING_TYPE_LOCATION,
THING_TYPE_THERMOSTAT, THING_TYPE_FIRMWARE_UPDATE, THING_TYPE_BRIDGE_1, THING_TYPE_BRIDGE_2,
THING_TYPE_BRIDGED_THING, THING_TYPE_CHATTY_THING, THING_TYPE_ROLLERSHUTTER, THING_TYPE_PLAYER,
- THING_TYPE_IMAGE, THING_TYPE_ACTION_MODULE, THING_TYPE_DYNAMIC_STATE_DESCRIPTION,
- THING_TYPE_ONLINE_OFFLINE);
+ THING_TYPE_IMAGE, THING_TYPE_ACTION_MODULE, THING_TYPE_DYNAMIC_STATE_DESCRIPTION, THING_TYPE_ONLINE_OFFLINE,
+ THING_TYPE_TIMESERIES);
private final MagicDynamicCommandDescriptionProvider commandDescriptionProvider;
private final MagicDynamicStateDescriptionProvider stateDescriptionProvider;
@@ -125,6 +126,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return new MagicOnlineOfflineHandler(thing);
} else if (THING_TYPE_BRIDGE_1.equals(thingTypeUID) || THING_TYPE_BRIDGE_2.equals(thingTypeUID)) {
return new MagicBridgeHandler((Bridge) thing);
+ } else if (THING_TYPE_TIMESERIES.equals(thingTypeUID)) {
+ return new MagicTimeSeriesHandler(thing);
}
return null;
diff --git a/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/channel-types.xml b/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/channel-types.xml
index 0fb616e7b89..8615a04f435 100644
--- a/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/channel-types.xml
+++ b/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/channel-types.xml
@@ -160,4 +160,8 @@
time
+
+ Number
+
+
diff --git a/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/thing-types.xml
index f29bf39267b..ff8c88af0ff 100644
--- a/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/thing-types.xml
+++ b/bundles/org.openhab.core.test.magic/src/main/resources/OH-INF/thing/thing-types.xml
@@ -234,4 +234,44 @@
+
+
+
+ Demonstrates the use of TimeSeries as forecast.
+
+
+
+
+
+
+ The interval to send the generated data.
+ 600
+
+
+
+ How to generate the values.
+
+
+
+
+
+ RND
+
+
+
+ The minimum value.
+ 0
+
+
+
+ The maximum value.
+ 100
+
+
+
+ The number of values to generate.
+ 10
+
+
+
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseThingHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseThingHandler.java
index 91efce1e6f4..e5fa558b3bd 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseThingHandler.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseThingHandler.java
@@ -41,6 +41,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -60,6 +61,7 @@
* @author Stefan Bußweiler - Added new thing status handling, refactorings thing/bridge life cycle
* @author Kai Kreuzer - Refactored isLinked method to not use deprecated functions anymore
* @author Christoph Weitkamp - Moved OSGI ServiceTracker from BaseThingHandler to ThingHandlerCallback
+ * @author Jan N. Klug - added time series support
*/
@NonNullByDefault
public abstract class BaseThingHandler implements ThingHandler {
@@ -287,6 +289,36 @@ protected void updateState(String channelID, State state) {
updateState(channelUID, state);
}
+ /**
+ * Send a time series to the channel. This can be used to transfer historic data or forecasts.
+ *
+ * @param channelUID unique id of the channel
+ * @param timeSeries the {@link TimeSeries} that is sent
+ */
+ protected void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
+ synchronized (this) {
+ ThingHandlerCallback callback1 = this.callback;
+ if (callback1 != null) {
+ callback1.sendTimeSeries(channelUID, timeSeries);
+ } else {
+ logger.warn(
+ "Handler {} of thing {} tried sending to channel {} although the handler was already disposed.",
+ this.getClass().getSimpleName(), channelUID.getThingUID(), channelUID.getId());
+ }
+ }
+ }
+
+ /**
+ * Send a time series to the channel. This can be used to transfer historic data or forecasts.
+ *
+ * @param channelID id of the channel
+ * @param timeSeries the {@link TimeSeries} that is sent
+ */
+ protected void sendTimeSeries(String channelID, TimeSeries timeSeries) {
+ ChannelUID channelUID = new ChannelUID(this.getThing().getUID(), channelID);
+ sendTimeSeries(channelUID, timeSeries);
+ }
+
/**
* Emits an event for the given channel.
*
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/ThingHandlerCallback.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/ThingHandlerCallback.java
index 0f99a65435e..33763184d25 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/ThingHandlerCallback.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/ThingHandlerCallback.java
@@ -35,6 +35,7 @@
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
/**
* {@link ThingHandlerCallback} is callback interface for {@link ThingHandler}s. The implementation of a
@@ -65,6 +66,14 @@ public interface ThingHandlerCallback {
*/
void postCommand(ChannelUID channelUID, Command command);
+ /**
+ * Informs about a time series, whcihs is send from the channel.
+ *
+ * @param channelUID channel UID
+ * @param timeSeries time series
+ */
+ void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries);
+
/**
* Informs about an updated status of a thing.
*
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/CommunicationManager.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/CommunicationManager.java
index ab6d5f9dc15..10fb9d414a0 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/CommunicationManager.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/CommunicationManager.java
@@ -71,9 +71,11 @@
import org.openhab.core.thing.profiles.ProfileFactory;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.thing.profiles.TimeSeriesProfile;
import org.openhab.core.thing.profiles.TriggerProfile;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.Type;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@@ -90,6 +92,7 @@
* It mainly mediates commands, state updates and triggers from ThingHandlers to the framework and vice versa.
*
* @author Simon Kaufmann - Initial contribution factored out of ThingManger
+ * @author Jan N. Klug - Added time series support
*/
@NonNullByDefault
@Component(service = { EventSubscriber.class, CommunicationManager.class }, immediate = true)
@@ -520,6 +523,20 @@ public void postCommand(ChannelUID channelUID, Command command) {
});
}
+ public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
+ ThingUID thingUID = channelUID.getThingUID();
+ Thing thing = thingRegistry.get(thingUID);
+ handleCallFromHandler(channelUID, thing, profile -> {
+ // TODO: check which profiles need enhancements
+ if (profile instanceof TimeSeriesProfile timeSeriesProfile) {
+ timeSeriesProfile.onTimeSeriesFromHandler(timeSeries);
+ } else {
+ logger.warn("Profile '{}' on channel {} does not support time series.", profile.getProfileTypeUID(),
+ channelUID);
+ }
+ });
+ }
+
private void handleCallFromHandler(ChannelUID channelUID, @Nullable Thing thing, Consumer action) {
itemChannelLinkRegistry.getLinks(channelUID).forEach(link -> {
final Item item = getItem(link.getItemName());
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/ThingHandlerCallbackImpl.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/ThingHandlerCallbackImpl.java
index 1ffab2b1a34..184b652dc01 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/ThingHandlerCallbackImpl.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/ThingHandlerCallbackImpl.java
@@ -42,6 +42,7 @@
import org.openhab.core.thing.util.ThingHandlerHelper;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -70,6 +71,11 @@ public void postCommand(ChannelUID channelUID, Command command) {
thingManager.communicationManager.postCommand(channelUID, command);
}
+ @Override
+ public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
+ thingManager.communicationManager.sendTimeSeries(channelUID, timeSeries);
+ }
+
@Override
public void channelTriggered(Thing thing, ChannelUID channelUID, String event) {
thingManager.communicationManager.channelTriggered(thing, channelUID, event);
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/ProfileCallbackImpl.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/ProfileCallbackImpl.java
index a0f40e53059..6f30e255c72 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/ProfileCallbackImpl.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/ProfileCallbackImpl.java
@@ -33,6 +33,7 @@
import org.openhab.core.thing.util.ThingHandlerHelper;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.TypeParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -145,6 +146,19 @@ public void sendUpdate(State state) {
ItemEventFactory.createStateEvent(link.getItemName(), acceptedState, link.getLinkedUID().toString()));
}
+ @Override
+ public void sendTimeSeries(TimeSeries timeSeries) {
+ Item item = itemProvider.apply(link.getItemName());
+ if (item == null) {
+ logger.warn("Cannot send time series event '{}' for item '{}', because no item could be found.", timeSeries,
+ link.getItemName());
+ return;
+ }
+
+ eventPublisher.post(
+ ItemEventFactory.createTimeSeriesEvent(link.getItemName(), timeSeries, link.getLinkedUID().toString()));
+ }
+
@FunctionalInterface
public interface AcceptedTypeConverter {
@Nullable
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfile.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfile.java
index 1b5b7881aee..d54f02bc3ec 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfile.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfile.java
@@ -16,10 +16,11 @@
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileTypeUID;
-import org.openhab.core.thing.profiles.StateProfile;
import org.openhab.core.thing.profiles.SystemProfiles;
+import org.openhab.core.thing.profiles.TimeSeriesProfile;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
/**
* This is the default profile for stateful channels.
@@ -30,7 +31,7 @@
* @author Simon Kaufmann - Initial contribution
*/
@NonNullByDefault
-public class SystemDefaultProfile implements StateProfile {
+public class SystemDefaultProfile implements TimeSeriesProfile {
private final ProfileCallback callback;
@@ -58,6 +59,11 @@ public void onCommandFromHandler(Command command) {
callback.sendCommand(command);
}
+ @Override
+ public void onTimeSeriesFromHandler(TimeSeries timeSeries) {
+ callback.sendTimeSeries(timeSeries);
+ }
+
@Override
public void onStateUpdateFromItem(State state) {
}
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/ProfileCallback.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/ProfileCallback.java
index 7c2483c8cb3..2e0eeecd993 100644
--- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/ProfileCallback.java
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/ProfileCallback.java
@@ -16,6 +16,7 @@
import org.openhab.core.thing.link.ItemChannelLink;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
/**
* Gives access to the framework features for continuing the communication flow.
@@ -52,4 +53,11 @@ public interface ProfileCallback {
* @param state
*/
void sendUpdate(State state);
+
+ /**
+ * Send a {@link TimeSeries} update to the framework.
+ *
+ * @param timeSeries
+ */
+ void sendTimeSeries(TimeSeries timeSeries);
}
diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/TimeSeriesProfile.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/TimeSeriesProfile.java
new file mode 100644
index 00000000000..9625069bcce
--- /dev/null
+++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/profiles/TimeSeriesProfile.java
@@ -0,0 +1,32 @@
+/**
+ * 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.profiles;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link TimeSeriesProfile} extends the {@link StateProfile} to support {@link TimeSeries} updates
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public interface TimeSeriesProfile extends StateProfile {
+
+ /**
+ * If a binding sends a time-series to a channel, this method will be called for each linked item.
+ *
+ * @param timeSeries the time-series
+ */
+ void onTimeSeriesFromHandler(TimeSeries timeSeries);
+}
diff --git a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/CommunicationManagerConversionTest.java b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/CommunicationManagerConversionTest.java
deleted file mode 100644
index 432a4398321..00000000000
--- a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/CommunicationManagerConversionTest.java
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * 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;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.lang.reflect.InvocationTargetException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.stream.Stream;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-import org.openhab.core.items.Item;
-import org.openhab.core.library.items.CallItem;
-import org.openhab.core.library.items.ColorItem;
-import org.openhab.core.library.items.ContactItem;
-import org.openhab.core.library.items.DateTimeItem;
-import org.openhab.core.library.items.DimmerItem;
-import org.openhab.core.library.items.ImageItem;
-import org.openhab.core.library.items.LocationItem;
-import org.openhab.core.library.items.PlayerItem;
-import org.openhab.core.library.items.RollershutterItem;
-import org.openhab.core.library.items.StringItem;
-import org.openhab.core.library.types.DateTimeType;
-import org.openhab.core.library.types.DecimalType;
-import org.openhab.core.library.types.HSBType;
-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.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.RawType;
-import org.openhab.core.library.types.RewindFastforwardType;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.library.types.UpDownType;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-import org.openhab.core.types.Type;
-import org.openhab.core.types.UnDefType;
-
-/**
- * @author Jan N. Klug - Initial contribution
- */
-@NonNullByDefault
-public class CommunicationManagerConversionTest {
- // TODO: remove test - only to show CommunicationManager is too complex
-
- private static final List> ITEM_TYPES = List.of(CallItem.class, ColorItem.class,
- ContactItem.class, DateTimeItem.class, DimmerItem.class, ImageItem.class, LocationItem.class,
- PlayerItem.class, RollershutterItem.class, StringItem.class);
-
- private static final List> TYPES = List.of(DateTimeType.class, DecimalType.class,
- HSBType.class, IncreaseDecreaseType.class, NextPreviousType.class, OnOffType.class, OpenClosedType.class,
- PercentType.class, PlayPauseType.class, PointType.class, QuantityType.class, RawType.class,
- RewindFastforwardType.class, StringType.class, UpDownType.class, UnDefType.class);
-
- private static Stream arguments()
- throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
- List arguments = new ArrayList<>();
- for (Class extends Item> itemType : ITEM_TYPES) {
- Item item = itemType.getDeclaredConstructor(String.class).newInstance("testItem");
- for (Class extends Type> type : TYPES) {
- if (type.isEnum()) {
- arguments.add(Arguments.of(item, type.getEnumConstants()[0]));
- } else if (type == RawType.class) {
- arguments.add(Arguments.of(item, new RawType(new byte[] {}, "mimeType")));
- } else {
- arguments.add(Arguments.of(item, type.getDeclaredConstructor().newInstance()));
- }
- }
- }
- return arguments.stream();
- }
-
- @Disabled
- @MethodSource("arguments")
- @ParameterizedTest
- public void testCommand(Item item, Type originalType) {
- Type returnType = null;
-
- List> acceptedTypes = item.getAcceptedCommandTypes();
- if (acceptedTypes.contains(originalType.getClass())) {
- returnType = originalType;
- } else {
- // Look for class hierarchy and convert appropriately
- for (Class extends Type> typeClass : acceptedTypes) {
- if (!typeClass.isEnum() && typeClass.isAssignableFrom(originalType.getClass()) //
- && State.class.isAssignableFrom(typeClass) && originalType instanceof State state) {
- returnType = state.as((Class extends State>) typeClass);
- }
- }
- }
-
- if (returnType != null && !returnType.getClass().equals(originalType.getClass())) {
- fail("CommunicationManager did a conversion for target item " + item.getType() + " from "
- + originalType.getClass() + " to " + returnType.getClass());
- }
- }
-
- @MethodSource("arguments")
- @ParameterizedTest
- public void testState(Item item, Type originalType) {
- Type returnType = null;
-
- List> acceptedTypes = item.getAcceptedDataTypes();
- if (acceptedTypes.contains(originalType.getClass())) {
- returnType = originalType;
- } else {
- // Look for class hierarchy and convert appropriately
- for (Class extends Type> typeClass : acceptedTypes) {
- if (!typeClass.isEnum() && typeClass.isAssignableFrom(originalType.getClass()) //
- && State.class.isAssignableFrom(typeClass) && originalType instanceof State state) {
- returnType = state.as((Class extends State>) typeClass);
-
- }
- }
- }
-
- if (returnType != null && !returnType.equals(originalType)) {
- fail("CommunicationManager did a conversion for target item " + item.getType() + " from "
- + originalType.getClass() + " to " + returnType.getClass());
- }
- }
-}
diff --git a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfileTest.java b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfileTest.java
index 94952d03acf..720f0008515 100644
--- a/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfileTest.java
+++ b/bundles/org.openhab.core.thing/src/test/java/org/openhab/core/thing/internal/profiles/SystemDefaultProfileTest.java
@@ -22,6 +22,7 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.types.TimeSeries;
/**
*
@@ -60,4 +61,15 @@ public void testPostCommand() {
verify(callbackMock).sendCommand(eq(OnOffType.ON));
verifyNoMoreInteractions(callbackMock);
}
+
+ @Test
+ public void testSendTimeSeries() {
+ TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.ADD);
+
+ SystemDefaultProfile profile = new SystemDefaultProfile(callbackMock);
+ profile.onTimeSeriesFromHandler(timeSeries);
+
+ verify(callbackMock).sendTimeSeries(timeSeries);
+ verifyNoMoreInteractions(callbackMock);
+ }
}
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 8e3649e8804..5a2ed152919 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
@@ -112,6 +112,7 @@
* @author Mark Herwege - new method getFormatPattern(widget), clean pattern
* @author Laurent Garnier - Support added for multiple AND conditions in labelcolor/valuecolor/visibility
* @author Laurent Garnier - new icon parameter based on conditional rules
+ * @author Danny Baumann - widget label source support
*/
@NonNullByDefault
@Component(immediate = true, configurationPid = "org.openhab.sitemap", //
@@ -142,6 +143,16 @@ public class ItemUIRegistryImpl implements ItemUIRegistry {
private String groupMembersSorting = DEFAULT_SORTING;
+ private static class WidgetLabelWithSource {
+ public final String label;
+ public final WidgetLabelSource source;
+
+ public WidgetLabelWithSource(String l, WidgetLabelSource s) {
+ label = l;
+ source = s;
+ }
+ }
+
@Activate
public ItemUIRegistryImpl(@Reference ItemRegistry itemRegistry) {
this.itemRegistry = itemRegistry;
@@ -325,7 +336,7 @@ private Switch createPlayerButtons() {
@Override
public @Nullable String getLabel(Widget w) {
- String label = getLabelFromWidget(w);
+ String label = getLabelFromWidget(w).label;
String itemName = w.getItem();
if (itemName == null || itemName.isBlank()) {
@@ -468,6 +479,11 @@ private Switch createPlayerButtons() {
return transform(label, considerTransform, labelMappedOption);
}
+ @Override
+ public WidgetLabelSource getLabelSource(Widget w) {
+ return getLabelFromWidget(w).source;
+ }
+
private QuantityType> convertStateToWidgetUnit(QuantityType> quantityState, Widget w) {
Unit> widgetUnit = UnitUtils.parseUnit(getFormatPattern(w));
if (widgetUnit != null && !widgetUnit.equals(quantityState.getUnit())) {
@@ -479,7 +495,7 @@ private QuantityType> convertStateToWidgetUnit(QuantityType> quantityState,
@Override
public @Nullable String getFormatPattern(Widget w) {
- String label = getLabelFromWidget(w);
+ String label = getLabelFromWidget(w).label;
String pattern = getFormatPattern(label);
String itemName = w.getItem();
try {
@@ -543,24 +559,30 @@ private QuantityType> convertStateToWidgetUnit(QuantityType> quantityState,
}
}
- private String getLabelFromWidget(Widget w) {
+ private WidgetLabelWithSource getLabelFromWidget(Widget w) {
String label = null;
+ WidgetLabelSource source = WidgetLabelSource.NONE;
+
if (w.getLabel() != null) {
// if there is a label defined for the widget, use this
label = w.getLabel();
+ source = WidgetLabelSource.SITEMAP_WIDGET;
} else {
String itemName = w.getItem();
if (itemName != null) {
// check if any item ui provider provides a label for this item
label = getLabel(itemName);
// if there is no item ui provider saying anything, simply use the name as a label
- if (label == null) {
+ if (label != null) {
+ source = WidgetLabelSource.ITEM_LABEL;
+ } else {
label = itemName;
+ source = WidgetLabelSource.ITEM_NAME;
}
}
}
// use an empty string, if no label could be found
- return label != null ? label : "";
+ return new WidgetLabelWithSource(label != null ? label : "", source);
}
/**
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 81026e1ad64..f13c9230259 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
@@ -36,9 +36,20 @@
* @author Laurent Garnier - new method getIconColor
* @author Mark Herwege - new method getFormatPattern
* @author Laurent Garnier - new method getConditionalIcon
+ * @author Danny Baumann - widget label source support
*/
@NonNullByDefault
public interface ItemUIRegistry extends ItemRegistry, ItemUIProvider {
+ public enum WidgetLabelSource {
+ /** Label is taken from widget definition in sitemap */
+ SITEMAP_WIDGET,
+ /** Label is taken from the widget's backing item definition */
+ ITEM_LABEL,
+ /** Label equals the widget's backing item name */
+ ITEM_NAME,
+ /** No suitable label source could be determined */
+ NONE
+ };
/**
* Retrieves the label for a widget.
@@ -57,6 +68,14 @@ public interface ItemUIRegistry extends ItemRegistry, ItemUIProvider {
@Nullable
String getLabel(Widget w);
+ /**
+ * Retrieves the label source for a widget.
+ *
+ * @param w the widget to retrieve the label source for
+ * @return the source the widget label is taken from
+ */
+ WidgetLabelSource getLabelSource(Widget w);
+
/**
* Retrieves the category for a widget.
*
diff --git a/bundles/org.openhab.core.ui/src/test/java/org/openhab/core/ui/internal/items/ItemUIRegistryImplTest.java b/bundles/org.openhab.core.ui/src/test/java/org/openhab/core/ui/internal/items/ItemUIRegistryImplTest.java
index f40747b81ae..967fa6b95bf 100644
--- a/bundles/org.openhab.core.ui/src/test/java/org/openhab/core/ui/internal/items/ItemUIRegistryImplTest.java
+++ b/bundles/org.openhab.core.ui/src/test/java/org/openhab/core/ui/internal/items/ItemUIRegistryImplTest.java
@@ -84,6 +84,7 @@
import org.openhab.core.types.UnDefType;
import org.openhab.core.types.util.UnitUtils;
import org.openhab.core.ui.items.ItemUIProvider;
+import org.openhab.core.ui.items.ItemUIRegistry.WidgetLabelSource;
/**
* @author Kai Kreuzer - Initial contribution
@@ -121,8 +122,8 @@ public void getLabelPlainLabel() {
String testLabel = "This is a plain text";
when(widgetMock.getLabel()).thenReturn(testLabel);
- String label = uiRegistry.getLabel(widgetMock);
- assertEquals(testLabel, label);
+ assertEquals(testLabel, uiRegistry.getLabel(widgetMock));
+ assertEquals(WidgetLabelSource.SITEMAP_WIDGET, uiRegistry.getLabelSource(widgetMock));
}
@Test
@@ -455,14 +456,14 @@ public void getLabelLabelWithZonedTime() throws ItemNotFoundException {
@Test
public void getLabelWidgetWithoutLabelAndItem() {
Widget w = mock(Widget.class);
- String label = uiRegistry.getLabel(w);
- assertEquals("", label);
+ assertEquals("", uiRegistry.getLabel(w));
+ assertEquals(WidgetLabelSource.NONE, uiRegistry.getLabelSource(w));
}
@Test
public void getLabelWidgetWithoutLabel() {
- String label = uiRegistry.getLabel(widgetMock);
- assertEquals(ITEM_NAME, label);
+ assertEquals(ITEM_NAME, uiRegistry.getLabel(widgetMock));
+ assertEquals(WidgetLabelSource.ITEM_NAME, uiRegistry.getLabelSource(widgetMock));
}
@Test
@@ -470,8 +471,8 @@ public void getLabelLabelFromUIProvider() {
ItemUIProvider provider = mock(ItemUIProvider.class);
uiRegistry.addItemUIProvider(provider);
when(provider.getLabel(anyString())).thenReturn("ProviderLabel");
- String label = uiRegistry.getLabel(widgetMock);
- assertEquals("ProviderLabel", label);
+ assertEquals("ProviderLabel", uiRegistry.getLabel(widgetMock));
+ assertEquals(WidgetLabelSource.ITEM_LABEL, uiRegistry.getLabelSource(widgetMock));
uiRegistry.removeItemUIProvider(provider);
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/text/StandardInterpreter.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/text/StandardInterpreter.java
index b9fc190986c..5bd2c7afbed 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/text/StandardInterpreter.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/internal/text/StandardInterpreter.java
@@ -12,12 +12,23 @@
*/
package org.openhab.core.voice.internal.text;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+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;
+import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.events.EventPublisher;
+import org.openhab.core.items.Item;
import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.IncreaseDecreaseType;
@@ -27,14 +38,19 @@
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.types.Command;
import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.TypeParser;
import org.openhab.core.voice.text.AbstractRuleBasedInterpreter;
import org.openhab.core.voice.text.Expression;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
+import org.openhab.core.voice.text.Rule;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* A human language command interpretation service.
@@ -42,15 +58,23 @@
* @author Tilman Kamp - Initial contribution
* @author Kai Kreuzer - Added further German interpretation rules
* @author Laurent Garnier - Added French interpretation rules
+ * @author Miguel Álvarez - Added Spanish interpretation rules
+ * @author Miguel Álvarez - Added item's dynamic rules
*/
@NonNullByDefault
@Component(service = HumanLanguageInterpreter.class)
public class StandardInterpreter extends AbstractRuleBasedInterpreter {
+ private Logger logger = LoggerFactory.getLogger(StandardInterpreter.class);
+ private final ItemRegistry itemRegistry;
+ private final String metadataNamespace = "voice-system";
+ private final MetadataRegistry metadataRegistry;
@Activate
public StandardInterpreter(final @Reference EventPublisher eventPublisher,
final @Reference ItemRegistry itemRegistry, @Reference MetadataRegistry metadataRegistry) {
super(eventPublisher, itemRegistry, metadataRegistry);
+ this.itemRegistry = itemRegistry;
+ this.metadataRegistry = metadataRegistry;
}
@Override
@@ -60,264 +84,319 @@ protected void deactivate() {
}
@Override
- public void createRules() {
- /****************************** ENGLISH ******************************/
+ public Set getSupportedLocales() {
+ return Set.of(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, new Locale("es"));
+ }
+
+ @Override
+ public void createRules(@Nullable Locale locale) {
- Expression onOff = alt(cmd("on", OnOffType.ON), cmd("off", OnOffType.OFF));
- Expression turn = alt("turn", "switch");
- Expression put = alt("put", "bring");
- Expression of = opt("of");
- Expression the = opt("the");
- Expression to = opt("to");
- Expression color = alt(cmd("white", HSBType.WHITE), cmd("pink", HSBType.fromRGB(255, 96, 208)),
- cmd("yellow", HSBType.fromRGB(255, 224, 32)), cmd("orange", HSBType.fromRGB(255, 160, 16)),
- cmd("purple", HSBType.fromRGB(128, 0, 128)), cmd("red", HSBType.RED), cmd("green", HSBType.GREEN),
- cmd("blue", HSBType.BLUE));
+ /****************************** ENGLISH ******************************/
- addRules(Locale.ENGLISH,
+ if (locale == null || Objects.equals(locale.getLanguage(), Locale.ENGLISH.getLanguage())) {
+ Expression onOff = alt(cmd("on", OnOffType.ON), cmd("off", OnOffType.OFF));
+ Expression turn = alt("turn", "switch");
+ Expression put = alt("put", "bring");
+ Expression of = opt("of");
+ Expression the = opt("the");
+ Expression to = opt("to");
+ Expression color = alt(cmd("white", HSBType.WHITE), cmd("pink", HSBType.fromRGB(255, 96, 208)),
+ cmd("yellow", HSBType.fromRGB(255, 224, 32)), cmd("orange", HSBType.fromRGB(255, 160, 16)),
+ cmd("purple", HSBType.fromRGB(128, 0, 128)), cmd("red", HSBType.RED), cmd("green", HSBType.GREEN),
+ cmd("blue", HSBType.BLUE));
+ addRules(Locale.ENGLISH,
- /* OnOffType */
+ /* OnOffType */
- itemRule(seq(turn, the), /* item */ onOff),
+ itemRule(seq(turn, the), /* item */ onOff),
- itemRule(seq(turn, onOff) /* item */),
+ itemRule(seq(turn, onOff) /* item */),
- /* IncreaseDecreaseType */
+ /* IncreaseDecreaseType */
- itemRule(seq(cmd(alt("dim", "decrease", "lower", "soften"), IncreaseDecreaseType.DECREASE), the) /*
- * item
- */),
+ itemRule(seq(cmd(alt("dim", "decrease", "lower", "soften"), IncreaseDecreaseType.DECREASE),
+ the) /*
+ * item
+ */),
- itemRule(seq(cmd(alt("brighten", "increase", "harden", "enhance"), IncreaseDecreaseType.INCREASE),
- the) /* item */),
+ itemRule(seq(cmd(alt("brighten", "increase", "harden", "enhance"), IncreaseDecreaseType.INCREASE),
+ the) /* item */),
- /* ColorType */
+ /* ColorType */
- itemRule(seq(opt("set"), the, opt("color"), of, the), /* item */ seq(to, color)),
+ itemRule(seq(opt("set"), the, opt("color"), of, the), /* item */ seq(to, color)),
- /* UpDownType */
+ /* UpDownType */
- itemRule(seq(put, the), /* item */ cmd("up", UpDownType.UP)),
+ itemRule(seq(put, the), /* item */ cmd("up", UpDownType.UP)),
- itemRule(seq(put, the), /* item */ cmd("down", UpDownType.DOWN)),
+ itemRule(seq(put, the), /* item */ cmd("down", UpDownType.DOWN)),
- /* NextPreviousType */
+ /* NextPreviousType */
- itemRule("move",
- /* item */ seq(opt("to"),
- alt(cmd("next", NextPreviousType.NEXT), cmd("previous", NextPreviousType.PREVIOUS)))),
+ itemRule("move",
+ /* item */ seq(opt("to"),
+ alt(cmd("next", NextPreviousType.NEXT),
+ cmd("previous", NextPreviousType.PREVIOUS)))),
- /* PlayPauseType */
+ /* PlayPauseType */
- itemRule(seq(cmd("play", PlayPauseType.PLAY), the) /* item */),
+ itemRule(seq(cmd("play", PlayPauseType.PLAY), the) /* item */),
- itemRule(seq(cmd("pause", PlayPauseType.PAUSE), the) /* item */),
+ itemRule(seq(cmd("pause", PlayPauseType.PAUSE), the) /* item */),
- /* RewindFastForwardType */
+ /* RewindFastForwardType */
- itemRule(seq(cmd("rewind", RewindFastforwardType.REWIND), the) /* item */),
+ itemRule(seq(cmd("rewind", RewindFastforwardType.REWIND), the) /* item */),
- itemRule(seq(cmd(seq(opt("fast"), "forward"), RewindFastforwardType.FASTFORWARD), the) /* item */),
+ itemRule(seq(cmd(seq(opt("fast"), "forward"), RewindFastforwardType.FASTFORWARD), the) /* item */),
- /* StopMoveType */
+ /* StopMoveType */
- itemRule(seq(cmd("stop", StopMoveType.STOP), the) /* item */),
+ itemRule(seq(cmd("stop", StopMoveType.STOP), the) /* item */),
- itemRule(seq(cmd(alt("start", "move", "continue"), StopMoveType.MOVE), the) /* item */),
+ itemRule(seq(cmd(alt("start", "move", "continue"), StopMoveType.MOVE), the) /* item */),
- /* RefreshType */
+ /* RefreshType */
- itemRule(seq(cmd("refresh", RefreshType.REFRESH), the) /* item */)
+ itemRule(seq(cmd("refresh", RefreshType.REFRESH), the) /* item */)
- );
+ );
+ /* Item description commands */
+ addRules(Locale.ENGLISH, createItemDescriptionRules( //
+ (allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, //
+ seq(alt("set", "change"), opt(the)), /* item */ seq(to, labeledCmd)//
+ ), //
+ Locale.ENGLISH).toArray(Rule[]::new));
+ }
/****************************** GERMAN ******************************/
- Expression einAnAus = alt(cmd("ein", OnOffType.ON), cmd("an", OnOffType.ON), cmd("aus", OnOffType.OFF));
- Expression denDieDas = opt(alt("den", "die", "das"));
- Expression schalte = alt("schalt", "schalte", "mach");
- Expression pause = alt("pause", "stoppe");
- Expression mache = alt("mach", "mache", "fahre");
- Expression spiele = alt("spiele", "spiel", "starte");
- Expression zu = alt("zu", "zum", "zur");
- Expression naechste = alt("nächste", "nächstes", "nächster");
- Expression vorherige = alt("vorherige", "vorheriges", "vorheriger");
- Expression farbe = alt(cmd("weiß", HSBType.WHITE), cmd("pink", HSBType.fromRGB(255, 96, 208)),
- cmd("gelb", HSBType.fromRGB(255, 224, 32)), cmd("orange", HSBType.fromRGB(255, 160, 16)),
- cmd("lila", HSBType.fromRGB(128, 0, 128)), cmd("rot", HSBType.RED), cmd("grün", HSBType.GREEN),
- cmd("blau", HSBType.BLUE));
+ if (locale == null || Objects.equals(locale.getLanguage(), Locale.GERMAN.getLanguage())) {
+
+ Expression einAnAus = alt(cmd("ein", OnOffType.ON), cmd("an", OnOffType.ON), cmd("aus", OnOffType.OFF));
+ Expression denDieDas = opt(alt("den", "die", "das"));
+ Expression schalte = alt("schalt", "schalte", "mach");
+ Expression pause = alt("pause", "stoppe");
+ Expression mache = alt("mach", "mache", "fahre");
+ Expression spiele = alt("spiele", "spiel", "starte");
+ Expression zu = alt("zu", "zum", "zur");
+ Expression the = opt("the");
+ Expression naechste = alt("nächste", "nächstes", "nächster");
+ Expression vorherige = alt("vorherige", "vorheriges", "vorheriger");
+ Expression farbe = alt(cmd("weiß", HSBType.WHITE), cmd("pink", HSBType.fromRGB(255, 96, 208)),
+ cmd("gelb", HSBType.fromRGB(255, 224, 32)), cmd("orange", HSBType.fromRGB(255, 160, 16)),
+ cmd("lila", HSBType.fromRGB(128, 0, 128)), cmd("rot", HSBType.RED), cmd("grün", HSBType.GREEN),
+ cmd("blau", HSBType.BLUE));
- addRules(Locale.GERMAN,
+ addRules(Locale.GERMAN,
- /* OnOffType */
+ /* OnOffType */
- itemRule(seq(schalte, denDieDas), /* item */ einAnAus),
+ itemRule(seq(schalte, denDieDas), /* item */ einAnAus),
- /* IncreaseDecreaseType */
+ /* IncreaseDecreaseType */
- itemRule(seq(cmd(alt("dimme"), IncreaseDecreaseType.DECREASE), denDieDas) /* item */),
+ itemRule(seq(cmd(alt("dimme"), IncreaseDecreaseType.DECREASE), denDieDas) /* item */),
- itemRule(seq(schalte, denDieDas),
- /* item */ cmd(alt("dunkler", "weniger"), IncreaseDecreaseType.DECREASE)),
+ itemRule(seq(schalte, denDieDas),
+ /* item */ cmd(alt("dunkler", "weniger"), IncreaseDecreaseType.DECREASE)),
- itemRule(seq(schalte, denDieDas), /* item */ cmd(alt("heller", "mehr"), IncreaseDecreaseType.INCREASE)),
+ itemRule(seq(schalte, denDieDas),
+ /* item */ cmd(alt("heller", "mehr"), IncreaseDecreaseType.INCREASE)),
- /* ColorType */
+ /* ColorType */
- itemRule(seq(schalte, denDieDas), /* item */ seq(opt("auf"), farbe)),
+ itemRule(seq(schalte, denDieDas), /* item */ seq(opt("auf"), farbe)),
- /* UpDownType */
+ /* UpDownType */
- itemRule(seq(mache, denDieDas), /* item */ cmd("hoch", UpDownType.UP)),
+ itemRule(seq(mache, denDieDas), /* item */ cmd("hoch", UpDownType.UP)),
- itemRule(seq(mache, denDieDas), /* item */ cmd("runter", UpDownType.DOWN)),
+ itemRule(seq(mache, denDieDas), /* item */ cmd("runter", UpDownType.DOWN)),
- /* NextPreviousType */
+ /* NextPreviousType */
- itemRule("wechsle",
- /* item */ seq(opt(zu),
- alt(cmd(naechste, NextPreviousType.NEXT), cmd(vorherige, NextPreviousType.PREVIOUS)))),
+ itemRule("wechsle",
+ /* item */ seq(opt(zu),
+ alt(cmd(naechste, NextPreviousType.NEXT),
+ cmd(vorherige, NextPreviousType.PREVIOUS)))),
- /* PlayPauseType */
+ /* PlayPauseType */
- itemRule(seq(cmd(spiele, PlayPauseType.PLAY), the) /* item */),
+ itemRule(seq(cmd(spiele, PlayPauseType.PLAY), the) /* item */),
- itemRule(seq(cmd(pause, PlayPauseType.PAUSE), the) /* item */)
+ itemRule(seq(cmd(pause, PlayPauseType.PAUSE), the) /* item */)
- );
+ );
+
+ /* Item description commands */
+
+ addRules(Locale.GERMAN, createItemDescriptionRules( //
+ (allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, //
+ seq(schalte, denDieDas), /* item */ seq(opt("auf"), labeledCmd)//
+ ), //
+ Locale.GERMAN).toArray(Rule[]::new));
+
+ }
/****************************** FRENCH ******************************/
- Expression allume = alt("allume", "démarre", "active");
- Expression eteins = alt("éteins", "stoppe", "désactive", "coupe");
- Expression lela = opt(alt("le", "la", "les", "l"));
- Expression poursurdude = opt(alt("pour", "sur", "du", "de"));
- Expression couleur = alt(cmd("blanc", HSBType.WHITE), cmd("rose", HSBType.fromRGB(255, 96, 208)),
- cmd("jaune", HSBType.fromRGB(255, 224, 32)), cmd("orange", HSBType.fromRGB(255, 160, 16)),
- cmd("violet", HSBType.fromRGB(128, 0, 128)), cmd("rouge", HSBType.RED), cmd("vert", HSBType.GREEN),
- cmd("bleu", HSBType.BLUE));
+ if (locale == null || Objects.equals(locale.getLanguage(), Locale.FRENCH.getLanguage())) {
+ Expression allume = alt("allume", "démarre", "active");
+ Expression eteins = alt("éteins", "stoppe", "désactive", "coupe");
+ Expression lela = opt(alt("le", "la", "les", "l"));
+ Expression poursurdude = opt(alt("pour", "sur", "du", "de"));
+ Expression couleur = alt(cmd("blanc", HSBType.WHITE), cmd("rose", HSBType.fromRGB(255, 96, 208)),
+ cmd("jaune", HSBType.fromRGB(255, 224, 32)), cmd("orange", HSBType.fromRGB(255, 160, 16)),
+ cmd("violet", HSBType.fromRGB(128, 0, 128)), cmd("rouge", HSBType.RED), cmd("vert", HSBType.GREEN),
+ cmd("bleu", HSBType.BLUE));
+
+ addRules(Locale.FRENCH,
+
+ /* OnOffType */
- addRules(Locale.FRENCH,
+ itemRule(seq(cmd(allume, OnOffType.ON), lela) /* item */),
+ itemRule(seq(cmd(eteins, OnOffType.OFF), lela) /* item */),
- /* OnOffType */
+ /* IncreaseDecreaseType */
- itemRule(seq(cmd(allume, OnOffType.ON), lela) /* item */),
- itemRule(seq(cmd(eteins, OnOffType.OFF), lela) /* item */),
+ itemRule(seq(cmd("augmente", IncreaseDecreaseType.INCREASE), lela) /* item */),
+ itemRule(seq(cmd("diminue", IncreaseDecreaseType.DECREASE), lela) /* item */),
- /* IncreaseDecreaseType */
+ itemRule(seq(cmd("plus", IncreaseDecreaseType.INCREASE), "de") /* item */),
+ itemRule(seq(cmd("moins", IncreaseDecreaseType.DECREASE), "de") /* item */),
- itemRule(seq(cmd("augmente", IncreaseDecreaseType.INCREASE), lela) /* item */),
- itemRule(seq(cmd("diminue", IncreaseDecreaseType.DECREASE), lela) /* item */),
+ /* ColorType */
- itemRule(seq(cmd("plus", IncreaseDecreaseType.INCREASE), "de") /* item */),
- itemRule(seq(cmd("moins", IncreaseDecreaseType.DECREASE), "de") /* item */),
+ itemRule(seq("couleur", couleur, opt("pour"), lela) /* item */),
- /* ColorType */
+ /* PlayPauseType */
- itemRule(seq("couleur", couleur, opt("pour"), lela) /* item */),
+ itemRule(seq(cmd("reprise", PlayPauseType.PLAY), "lecture", poursurdude, lela) /* item */),
+ itemRule(seq(cmd("pause", PlayPauseType.PAUSE), "lecture", poursurdude, lela) /* item */),
- /* PlayPauseType */
+ /* NextPreviousType */
- itemRule(seq(cmd("reprise", PlayPauseType.PLAY), "lecture", poursurdude, lela) /* item */),
- itemRule(seq(cmd("pause", PlayPauseType.PAUSE), "lecture", poursurdude, lela) /* item */),
+ itemRule(seq(alt("plage", "piste"),
+ alt(cmd("suivante", NextPreviousType.NEXT), cmd("précédente", NextPreviousType.PREVIOUS)),
+ poursurdude, lela) /* item */),
- /* NextPreviousType */
+ /* UpDownType */
- itemRule(
- seq(alt("plage", "piste"),
- alt(cmd("suivante", NextPreviousType.NEXT),
- cmd("précédente", NextPreviousType.PREVIOUS)),
- poursurdude, lela) /* item */),
+ itemRule(seq(cmd("monte", UpDownType.UP), lela) /* item */),
+ itemRule(seq(cmd("descends", UpDownType.DOWN), lela) /* item */),
- /* UpDownType */
+ /* StopMoveType */
- itemRule(seq(cmd("monte", UpDownType.UP), lela) /* item */),
- itemRule(seq(cmd("descends", UpDownType.DOWN), lela) /* item */),
+ itemRule(seq(cmd("arrête", StopMoveType.STOP), lela) /* item */),
+ itemRule(seq(cmd(alt("bouge", "déplace"), StopMoveType.MOVE), lela) /* item */),
- /* StopMoveType */
+ /* RefreshType */
- itemRule(seq(cmd("arrête", StopMoveType.STOP), lela) /* item */),
- itemRule(seq(cmd(alt("bouge", "déplace"), StopMoveType.MOVE), lela) /* item */),
+ itemRule(seq(cmd("rafraîchis", RefreshType.REFRESH), lela) /* item */)
- /* RefreshType */
+ );
- itemRule(seq(cmd("rafraîchis", RefreshType.REFRESH), lela) /* item */)
+ /* Item description commands */
- );
+ addRules(Locale.FRENCH, createItemDescriptionRules( //
+ (allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, //
+ seq("mets", lela), /* item */ seq(poursurdude, lela, labeledCmd)//
+ ), //
+ Locale.FRENCH).toArray(Rule[]::new));
+ }
/****************************** SPANISH ******************************/
- Expression encenderApagar = alt(cmd(alt("enciende", "encender"), OnOffType.ON),
- cmd(alt("apaga", "apagar"), OnOffType.OFF));
- Expression cambia = alt("cambia", "cambiar");
- Expression poner = alt("pon", "poner");
- Expression de = opt("de");
- Expression articulo = opt(alt("el", "la"));
- Expression nombreColor = alt(cmd("blanco", HSBType.WHITE), cmd("rosa", HSBType.fromRGB(255, 96, 208)),
- cmd("amarillo", HSBType.fromRGB(255, 224, 32)), cmd("naranja", HSBType.fromRGB(255, 160, 16)),
- cmd("púrpura", HSBType.fromRGB(128, 0, 128)), cmd("rojo", HSBType.RED), cmd("verde", HSBType.GREEN),
- cmd("azul", HSBType.BLUE));
+ Locale localeES = new Locale("es");
+ if (locale == null || Objects.equals(locale.getLanguage(), localeES.getLanguage())) {
+ Expression encenderApagar = alt(cmd(alt("enciende", "encender"), OnOffType.ON),
+ cmd(alt("apaga", "apagar"), OnOffType.OFF));
+ Expression cambiar = alt("cambia", "cambiar");
+ Expression poner = alt("pon", "poner");
+ Expression preposicion = opt(alt("a", "de", "en"));
+ Expression articulo = opt(alt("el", "la", "los", "las"));
+ Expression nombreColor = alt(cmd("blanco", HSBType.WHITE), cmd("rosa", HSBType.fromRGB(255, 96, 208)),
+ cmd("amarillo", HSBType.fromRGB(255, 224, 32)), cmd("naranja", HSBType.fromRGB(255, 160, 16)),
+ cmd("púrpura", HSBType.fromRGB(128, 0, 128)), cmd("rojo", HSBType.RED), cmd("verde", HSBType.GREEN),
+ cmd("azul", HSBType.BLUE));
- addRules(new Locale("es"),
+ addRules(localeES,
- /* OnOffType */
+ /* OnOffType */
- itemRule(seq(encenderApagar, articulo)/* item */),
+ itemRule(seq(encenderApagar, articulo)/* item */),
- /* IncreaseDecreaseType */
+ /* IncreaseDecreaseType */
- itemRule(seq(cmd(alt("baja", "suaviza", "bajar", "suavizar"), IncreaseDecreaseType.DECREASE),
- articulo) /*
- * item
- */),
+ itemRule(seq(cmd(alt("baja", "suaviza", "bajar", "suavizar"), IncreaseDecreaseType.DECREASE),
+ articulo) /*
+ * item
+ */),
- itemRule(seq(cmd(alt("sube", "aumenta", "subir", "aumentar"), IncreaseDecreaseType.INCREASE),
- articulo) /* item */),
+ itemRule(seq(cmd(alt("sube", "aumenta", "subir", "aumentar"), IncreaseDecreaseType.INCREASE),
+ articulo) /* item */),
- /* ColorType */
+ /* ColorType */
- itemRule(seq(cambia, articulo, opt("color"), de, articulo), /* item */ seq(opt("a"), nombreColor)),
+ itemRule(seq(cambiar, articulo, opt("color"), preposicion, articulo),
+ /* item */ seq(opt("a"), nombreColor)),
- /* UpDownType */
+ /* UpDownType */
- itemRule(seq(poner, articulo), /* item */ cmd("arriba", UpDownType.UP)),
+ itemRule(seq(poner, articulo), /* item */ cmd("arriba", UpDownType.UP)),
- itemRule(seq(poner, articulo), /* item */ cmd("abajo", UpDownType.DOWN)),
+ itemRule(seq(poner, articulo), /* item */ cmd("abajo", UpDownType.DOWN)),
- /* NextPreviousType */
+ /* NextPreviousType */
- itemRule(alt("cambiar", "cambia"),
- /* item */ seq(opt("a"),
- alt(cmd("siguiente", NextPreviousType.NEXT),
- cmd("anterior", NextPreviousType.PREVIOUS)))),
+ itemRule(cambiar,
+ /* item */ seq(opt("a"),
+ alt(cmd("siguiente", NextPreviousType.NEXT),
+ cmd("anterior", NextPreviousType.PREVIOUS)))),
- /* PlayPauseType */
+ /* PlayPauseType */
- itemRule(seq(cmd(alt("continuar", "continua", "reanudar", "reanuda", "play"), PlayPauseType.PLAY),
- articulo) /*
- * item
- */),
+ itemRule(seq(cmd(alt("continuar", "continúa", "reanudar", "reanuda", "play"), PlayPauseType.PLAY),
+ alt(articulo, "en")) /*
+ * item
+ */),
- itemRule(seq(cmd(alt("pausa", "pausar"), PlayPauseType.PAUSE), articulo) /* item */),
+ itemRule(seq(cmd(alt("pausa", "pausar", "detén", "detener"), PlayPauseType.PAUSE),
+ alt(articulo, "en")) /*
+ * item
+ */),
- /* RewindFastForwardType */
+ /* RewindFastForwardType */
- itemRule(seq(cmd(alt("rebobina", "rebobinar"), RewindFastforwardType.REWIND), articulo) /* item */),
+ itemRule(seq(cmd(alt("rebobina", "rebobinar"), RewindFastforwardType.REWIND),
+ alt(articulo, "en")) /* item */),
- itemRule(seq(cmd(alt("avanza", "avanzar"), RewindFastforwardType.FASTFORWARD), articulo) /* item */),
+ itemRule(seq(cmd(alt("avanza", "avanzar"), RewindFastforwardType.FASTFORWARD),
+ alt(articulo, "en")) /* item */),
- /* StopMoveType */
+ /* StopMoveType */
- itemRule(seq(cmd(alt("para", "parar", "stop"), StopMoveType.STOP), articulo) /* item */),
+ itemRule(seq(cmd(alt("para", "parar", "stop"), StopMoveType.STOP), articulo) /* item */),
- itemRule(seq(cmd(alt("mueve", "mover"), StopMoveType.MOVE), articulo) /* item */),
+ itemRule(seq(cmd(alt("mueve", "mover"), StopMoveType.MOVE), articulo) /* item */),
- /* RefreshType */
+ /* RefreshType */
- itemRule(seq(cmd(alt("recarga", "refresca", "recargar", "refrescar"), RefreshType.REFRESH),
- articulo) /* item */)
+ itemRule(seq(cmd(alt("recarga", "refresca", "recargar", "refrescar"), RefreshType.REFRESH),
+ articulo) /* item */)
- );
+ );
+
+ /* Item description commands */
+
+ addRules(localeES, createItemDescriptionRules( //
+ (allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, //
+ seq(alt(cambiar, poner), opt(articulo)), /* item */ seq(preposicion, labeledCmd)//
+ ), //
+ localeES).toArray(Rule[]::new));
+ }
}
@Override
@@ -329,4 +408,101 @@ public String getId() {
public String getLabel(@Nullable Locale locale) {
return "Built-in Interpreter";
}
+
+ private List createItemDescriptionRules(CreateItemDescriptionRule creator, @Nullable Locale locale) {
+ // Map different item state/command labels with theirs values by item
+ HashMap> options = new HashMap<>();
+ List customRules = new ArrayList<>();
+ for (var item : itemRegistry.getItems()) {
+ customRules.addAll(createItemCustomRules(item));
+ var stateDesc = item.getStateDescription(locale);
+ if (stateDesc != null) {
+ stateDesc.getOptions().forEach(op -> {
+ var label = op.getLabel();
+ if (label == null || label.isBlank()) {
+ label = op.getValue();
+ }
+ var optionValueByItem = options.getOrDefault(label, new HashMap<>());
+ optionValueByItem.put(item, op.getValue());
+ options.put(label, optionValueByItem);
+ });
+ }
+ var commandDesc = item.getCommandDescription(locale);
+ if (commandDesc != null) {
+ commandDesc.getCommandOptions().forEach(op -> {
+ var label = op.getLabel();
+ if (label == null || label.isBlank()) {
+ label = op.getCommand();
+ }
+ var optionValueByItem = options.getOrDefault(label, new HashMap<>());
+ optionValueByItem.put(item, op.getCommand());
+ options.put(label, optionValueByItem);
+ });
+ }
+ }
+ // create rules
+ return Stream.concat(customRules.stream(), options.entrySet().stream() //
+ .map(entry -> {
+ String label = entry.getKey();
+ Map
- commandByItem = entry.getValue();
+ List itemNames = commandByItem.keySet().stream().map(Item::getName).toList();
+ String[] labelParts = Arrays.stream(label.split("\\s")).filter(p -> !p.isBlank())
+ .toArray(String[]::new);
+ Expression labeledCmd = cmd(seq((Object[]) labelParts),
+ new ItemStateCommandSupplier(label, commandByItem));
+ return creator.itemDescriptionRule(itemNames, labeledCmd);
+ })) //
+ .collect(Collectors.toList());
+ }
+
+ private List createItemCustomRules(Item item) {
+ var interpreterMetadata = metadataRegistry.get(new MetadataKey(metadataNamespace, item.getName()));
+ if (interpreterMetadata == null) {
+ return List.of();
+ }
+ List list = new ArrayList<>();
+ for (String s : interpreterMetadata.getValue().split("\n")) {
+ String line = s.trim();
+ Rule rule = this.parseItemCustomRule(item, line);
+ if (rule != null) {
+ list.add(rule);
+ }
+ }
+ return list;
+ }
+
+ private interface CreateItemDescriptionRule {
+ Rule itemDescriptionRule(List allowedItemNames, Expression labeledCmd);
+ }
+
+ private record ItemStateCommandSupplier(String label,
+ Map
- commandByItem) implements ItemCommandSupplier {
+ @Override
+ public @Nullable Command getItemCommand(Item item) {
+ String textCommand = commandByItem.get(item);
+ if (textCommand == null) {
+ return null;
+ }
+ return TypeParser.parseCommand(item.getAcceptedCommandTypes(), textCommand);
+ }
+
+ @Override
+ public String getCommandLabel() {
+ return label;
+ }
+
+ @Override
+ public List> getCommandClasses(@Nullable Item item) {
+ if (item == null) {
+ return commandByItem.keySet().stream()//
+ .flatMap(i -> i.getAcceptedCommandTypes().stream())//
+ .distinct()//
+ .collect(Collectors.toList());
+ } else if (commandByItem.containsKey(item)) {
+ return item.getAcceptedCommandTypes();
+ } else {
+ return List.of();
+ }
+ }
+ }
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/AbstractRuleBasedInterpreter.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/AbstractRuleBasedInterpreter.java
index 9e11ebfe157..4095b2e2702 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/AbstractRuleBasedInterpreter.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/AbstractRuleBasedInterpreter.java
@@ -12,9 +12,9 @@
*/
package org.openhab.core.voice.text;
+import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -25,6 +25,7 @@
import java.util.Set;
import java.util.stream.Collectors;
+import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
@@ -41,6 +42,7 @@
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
+import org.openhab.core.types.TypeParser;
import org.openhab.core.voice.DialogContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -71,6 +73,7 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
private static final String CMD = "cmd";
private static final String NAME = "name";
+ private static final String VALUE = "name";
private static final String LANGUAGE_SUPPORT = "LanguageSupport";
@@ -140,9 +143,9 @@ protected void deactivate() {
}
/**
- * Called whenever the rules are to be (re)generated and added by {@link addRules}
+ * Called whenever the rules are to be (re)generated and added by {@link #addRules}
*/
- protected abstract void createRules();
+ protected abstract void createRules(@Nullable Locale locale);
@Override
public String interpret(Locale locale, String text) throws InterpretationException {
@@ -165,8 +168,9 @@ public String interpret(Locale locale, String text, @Nullable DialogContext dial
InterpretationResult result;
InterpretationResult lastResult = null;
+ String locationItem = dialogContext != null ? dialogContext.locationItem() : null;
for (Rule rule : rules) {
- if ((result = rule.execute(language, tokens, dialogContext)).isSuccess()) {
+ if ((result = rule.execute(language, tokens, locationItem)).isSuccess()) {
return result.getResponse();
} else {
if (!InterpretationResult.SYNTAX_ERROR.equals(result)) {
@@ -264,7 +268,7 @@ private void addItem(Locale locale, Map
- target
/**
* Creates an item name placeholder expression. This expression is greedy: Only use it, if there are no other
* expressions following this one.
- * It's safer to use {@link thingRule} instead.
+ * It's safer to use {@link #itemRule} instead.
*
* @return Expression that represents a name of an item.
*/
@@ -275,7 +279,7 @@ protected Expression name() {
/**
* Creates an item name placeholder expression. This expression is greedy: Only use it, if you are able to pass in
* all possible stop tokens as excludes.
- * It's safer to use {@link thingRule} instead.
+ * It's safer to use {@link #itemRule} instead.
*
* @param stopper Stop expression that, if matching, will stop this expression from consuming further tokens.
* @return Expression that represents a name of an item.
@@ -284,11 +288,23 @@ protected Expression name(@Nullable Expression stopper) {
return tag(NAME, star(new ExpressionIdentifier(this, stopper)));
}
- private Map> getLanguageRules() {
- if (languageRules.isEmpty()) {
- createRules();
+ /**
+ * Creates an item value placeholder expression. This expression is greedy: Only use it, if you are able to pass in
+ * all possible stop tokens as excludes.
+ * It's safer to use {@link #itemRule} instead.
+ *
+ * @param stopper Stop expression that, if matching, will stop this expression from consuming further tokens.
+ * @return Expression that represents a name of an item.
+ */
+ private Expression value(@Nullable Expression stopper) {
+ return tag(VALUE, star(new ExpressionIdentifier(this, stopper)));
+ }
+
+ private @Nullable List<@NonNull Rule> getLanguageRules(@Nullable Locale locale) {
+ if (!languageRules.containsKey(locale)) {
+ createRules(locale);
}
- return languageRules;
+ return languageRules.get(locale);
}
/**
@@ -299,15 +315,13 @@ private Map> getLanguageRules() {
* @return Rules in descending match priority order.
*/
public Rule[] getRules(Locale locale) {
- Map> languageRules = getLanguageRules();
List rules = new ArrayList<>();
Set
> ruleSets = new HashSet<>();
- List ruleSet = languageRules.get(locale);
+ List ruleSet = getLanguageRules(locale);
if (ruleSet != null) {
ruleSets.add(ruleSet);
rules.addAll(ruleSet);
}
-
String language = locale.getLanguage();
for (Entry> entry : languageRules.entrySet()) {
Locale ruleLocale = entry.getKey();
@@ -323,7 +337,7 @@ public Rule[] getRules(Locale locale) {
}
/**
- * Adds {@link Locale} specific rules to this interpreter. To be called from within {@link createRules}.
+ * Adds {@link Locale} specific rules to this interpreter. To be called from within {@link #createRules}.
*
* @param locale Locale of the rules.
* @param rules Rules to add.
@@ -343,7 +357,7 @@ protected void addRules(Locale locale, Rule... rules) {
* item
* name expression.
*
- * @param headExpression The head expression that should contain at least one {@link cmd} generated expression. The
+ * @param headExpression The head expression that should contain at least one {@link #cmd} generated expression. The
* corresponding {@link Command} will in case of a match be sent to the matching {@link Item}.
* @return The created rule.
*/
@@ -354,7 +368,7 @@ protected Rule itemRule(Object headExpression) {
/**
* Creates an item rule on base of a head and a tail expression, where the middle part of the new rule's expression
* will consist of an item
- * name expression. Either the head expression or the tail expression should contain at least one {@link cmd}
+ * name expression. Either the head expression or the tail expression should contain at least one {@link #cmd}
* generated expression.
*
* @param headExpression The head expression.
@@ -362,27 +376,44 @@ protected Rule itemRule(Object headExpression) {
* @return The created rule.
*/
protected Rule itemRule(Object headExpression, @Nullable Object tailExpression) {
+ return restrictedItemRule(List.of(), headExpression, tailExpression);
+ }
+
+ /**
+ * Creates an item rule on base of a head and a tail expression, where the middle part of the new rule's ex ression
+ * will consist of an item
+ * name expression. Either the head expression or the tail expression should contain at least one {@link #cmd}
+ * generated expression.
+ * Rule will be restricted to the provided item names if any.
+ *
+ * @param allowedItemNames List of allowed item names, empty for disabled.
+ * @param headExpression The head expression.
+ * @param tailExpression The tail expression.
+ * @return The created rule.
+ */
+ protected Rule restrictedItemRule(List allowedItemNames, Object headExpression,
+ @Nullable Object tailExpression) {
Expression tail = exp(tailExpression);
Expression expression = tail == null ? seq(headExpression, name()) : seq(headExpression, name(tail), tail);
- return new Rule(expression) {
+ return new Rule(expression, allowedItemNames) {
@Override
public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
- @Nullable DialogContext dialogContext) {
+ InterpretationContext context) {
String[] name = node.findValueAsStringArray(NAME);
ASTNode cmdNode = node.findNode(CMD);
Object tag = cmdNode.getTag();
Object value = cmdNode.getValue();
- Command command;
- if (tag instanceof Command command1) {
- command = command1;
+ ItemCommandSupplier commandSupplier;
+ if (tag instanceof ItemCommandSupplier supplier) {
+ commandSupplier = supplier;
} else if (value instanceof Number number) {
- command = new DecimalType(number.longValue());
+ commandSupplier = new SingleCommandSupplier(new DecimalType(number.longValue()));
} else {
- command = new StringType(cmdNode.getValueAsString());
+ commandSupplier = new SingleCommandSupplier(new StringType(cmdNode.getValueAsString()));
}
if (name != null) {
try {
- return new InterpretationResult(true, executeSingle(language, name, command, dialogContext));
+ return new InterpretationResult(true, executeSingle(language, name, commandSupplier, context));
} catch (InterpretationException ex) {
return new InterpretationResult(ex);
}
@@ -393,8 +424,64 @@ public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
}
/**
- * Converts an object to an expression. Objects that are already instances of {@link Expression} are just returned.
- * All others are converted to {@link match} expressions.
+ * Creates a custom rule on base of a head and a tail expression, where the middle part of the new rule's
+ * expression
+ * will consist of a free command to be captured. Either the head expression or the tail expression should contain
+ * at least one {@link #cmd}
+ * generated expression.
+ * Rule will be restricted to the provided item name.
+ *
+ * @param item Item target
+ * @param headExpression The head expression.
+ * @param tailExpression The tail expression.
+ * @return The created rule.
+ */
+ protected Rule customItemRule(Item item, Object headExpression, @Nullable Object tailExpression) {
+ Expression tail = exp(tailExpression);
+ Expression expression = tail == null ? seq(headExpression, value(null))
+ : seq(headExpression, value(tail), tail);
+
+ HashMap valuesByLabel = new HashMap<>();
+ var stateDescription = item.getStateDescription();
+ if (stateDescription != null) {
+ stateDescription.getOptions().forEach(op -> {
+ String label = op.getLabel();
+ if (label != null) {
+ valuesByLabel.put(label, op.getValue());
+ }
+ });
+ }
+ var commandDesc = item.getCommandDescription();
+ if (commandDesc != null) {
+ commandDesc.getCommandOptions().forEach(op -> {
+ String label = op.getLabel();
+ if (label != null) {
+ valuesByLabel.put(label, op.getCommand());
+ }
+ });
+ }
+ return new Rule(expression, List.of(item.getName())) {
+ @Override
+ public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
+ InterpretationContext context) {
+ String[] commandParts = node.findValueAsStringArray(VALUE);
+ if (commandParts != null && commandParts.length > 0) {
+ try {
+ return new InterpretationResult(true,
+ executeCustom(language, item, String.join(" ", commandParts).trim(), valuesByLabel));
+ } catch (InterpretationException ex) {
+ return new InterpretationResult(ex);
+ }
+ }
+ return InterpretationResult.SEMANTIC_ERROR;
+ }
+ };
+ }
+
+ /**
+ * Converts an object to an expression.
+ * Objects that are already instances of {@link Expression} are just returned.
+ * All others are converted to {@link Expression}.
*
* @param obj the object that's to be converted
* @return resulting expression
@@ -408,9 +495,9 @@ public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
}
/**
- * Converts all parameters to an expression array. Objects that are already instances of {@link Expression} are not
- * touched.
- * All others are converted to {@link match} expressions.
+ * Converts all parameters to an expression array.
+ * Objects that are already instances of {@link Expression} are not touched.
+ * All others are converted to {@link Expression}.
*
* @param objects the objects that are to be converted
* @return resulting expression array
@@ -468,7 +555,7 @@ protected Expression tag(@Nullable String name, Object expression, @Nullable Obj
* @return resulting expression
*/
protected Expression cmd(Object expression) {
- return cmd(expression, null);
+ return cmd(expression, (Command) null);
}
/**
@@ -479,6 +566,17 @@ protected Expression cmd(Object expression) {
* @return resulting expression
*/
protected Expression cmd(Object expression, @Nullable Command command) {
+ return tag(CMD, expression, command != null ? new SingleCommandSupplier(command) : null);
+ }
+
+ /**
+ * Adds command resolver to the resulting AST tree, if the expression matches.
+ *
+ * @param expression the expression that has to match
+ * @param command the command that should be added
+ * @return resulting expression
+ */
+ protected Expression cmd(Object expression, AbstractRuleBasedInterpreter.@Nullable ItemCommandSupplier command) {
return tag(CMD, expression, command);
}
@@ -548,17 +646,17 @@ protected ExpressionCardinality plus(Object expression) {
* @param labelFragments label fragments that are used to match an item's label.
* For a positive match, the item's label has to contain every fragment - independently of their order.
* They are treated case insensitive.
- * @param command command that should be executed
+ * @param commandSupplier supplies the command to be executed.
* @return response text
* @throws InterpretationException in case that there is no or more than on item matching the fragments
*/
- protected String executeSingle(ResourceBundle language, String[] labelFragments, Command command,
- @Nullable DialogContext dialogContext) throws InterpretationException {
- List- items = getMatchingItems(language, labelFragments, command.getClass(), dialogContext);
+ protected String executeSingle(ResourceBundle language, String[] labelFragments,
+ ItemCommandSupplier commandSupplier, Rule.InterpretationContext context) throws InterpretationException {
+ List
- items = getMatchingItems(language, labelFragments, commandSupplier, context);
if (items.isEmpty()) {
- if (!getMatchingItems(language, labelFragments, null, dialogContext).isEmpty()) {
+ if (!getMatchingItems(language, labelFragments, null, context).isEmpty()) {
throw new InterpretationException(
- language.getString(COMMAND_NOT_ACCEPTED).replace("", command.toString()));
+ language.getString(COMMAND_NOT_ACCEPTED).replace("", commandSupplier.getCommandLabel()));
} else {
throw new InterpretationException(language.getString(NO_OBJECTS));
}
@@ -566,28 +664,62 @@ protected String executeSingle(ResourceBundle language, String[] labelFragments,
throw new InterpretationException(language.getString(MULTIPLE_OBJECTS));
} else {
Item item = items.get(0);
- if (command instanceof State newState) {
- try {
- State oldState = item.getStateAs(newState.getClass());
- if (newState.equals(oldState)) {
- String template = language.getString(STATE_ALREADY_SINGULAR);
- String cmdName = "state_" + command.toString().toLowerCase();
- String stateText = null;
- try {
- stateText = language.getString(cmdName);
- } catch (Exception e) {
- stateText = language.getString(STATE_CURRENT);
- }
- return template.replace("", stateText);
+ Command command = commandSupplier.getItemCommand(item);
+ if (command == null) {
+ logger.warn("Failed resolving item command");
+ return language.getString(ERROR);
+ }
+ return trySendCommand(language, item, command);
+ }
+ }
+
+ /**
+ * Executes a custom rule command.
+ *
+ * @param item the rule target.
+ * @param options replacement values from item description.
+ * @param language resource bundle used for producing localized response texts
+ * @param commandText label fragments that are used to match an item's label.
+ * For a positive match, the item's label has to contain every fragment - independently of their order.
+ * They are treated case-insensitive.
+ * @return response text
+ * @throws InterpretationException in case that there is no or more than on item matching the fragments
+ */
+ protected String executeCustom(ResourceBundle language, Item item, String commandText,
+ HashMap options) throws InterpretationException {
+ @Nullable
+ String commandReplacement = options.get(commandText);
+ Command command = TypeParser.parseCommand(item.getAcceptedCommandTypes(),
+ commandReplacement != null ? commandReplacement : commandText);
+ if (command == null) {
+ logger.warn("Failed creating command for {} from {}", item, commandText);
+ return language.getString(ERROR);
+ }
+ return trySendCommand(language, item, command);
+ }
+
+ private String trySendCommand(ResourceBundle language, Item item, Command command) {
+ if (command instanceof State newState) {
+ try {
+ State oldState = item.getStateAs(newState.getClass());
+ if (newState.equals(oldState)) {
+ String template = language.getString(STATE_ALREADY_SINGULAR);
+ String cmdName = "state_" + command.toString().toLowerCase();
+ String stateText = null;
+ try {
+ stateText = language.getString(cmdName);
+ } catch (Exception e) {
+ stateText = language.getString(STATE_CURRENT);
}
- } catch (Exception ex) {
- logger.debug("Failed constructing response: {}", ex.getMessage());
- return language.getString(ERROR);
+ return template.replace("", stateText);
}
+ } catch (Exception ex) {
+ logger.debug("Failed constructing response: {}", ex.getMessage());
+ return language.getString(ERROR);
}
- eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command));
- return language.getString(OK);
}
+ eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command));
+ return language.getString(OK);
}
/**
@@ -605,18 +737,22 @@ protected String executeSingle(ResourceBundle language, String[] labelFragments,
* @param labelFragments label fragments that are used to match an item's label.
* For a positive match, the item's label has to contain every fragment - independently of their order.
* They are treated case-insensitive.
- * @param commandType optional command type that all items have to support.
- * Provide {null} if there is no need for a certain command to be supported.
+ * @param commandSupplier optional command supplier to access the command types an item have to support.
+ * Provide {null} if there is no need for a certain command type to be supported.
* @return All matching items from the item registry.
*/
protected List
- getMatchingItems(ResourceBundle language, String[] labelFragments,
- @Nullable Class> commandType, @Nullable DialogContext dialogContext) {
+ @Nullable ItemCommandSupplier commandSupplier, Rule.InterpretationContext context) {
Map
- itemsData = new HashMap<>();
Map
- exactMatchItemsData = new HashMap<>();
Map
- map = getItemTokens(language.getLocale());
for (Entry
- entry : map.entrySet()) {
Item item = entry.getKey();
ItemInterpretationMetadata interpretationMetadata = entry.getValue();
+ if (!context.allowedItems().isEmpty() && !context.allowedItems().contains(item.getName())) {
+ logger.trace("Item {} discarded, not allowed for this rule", item.getName());
+ continue;
+ }
for (List
> itemLabelFragmentsPath : interpretationMetadata.pathToItem) {
boolean exactMatch = false;
logger.trace("Checking tokens {} against the item tokens {}", labelFragments, itemLabelFragmentsPath);
@@ -635,7 +771,11 @@ protected List- getMatchingItems(ResourceBundle language, String[] labelFra
logger.trace("Matched: {}", allMatched);
logger.trace("Exact match: {}", exactMatch);
if (allMatched) {
- if (commandType == null || item.getAcceptedCommandTypes().contains(commandType)) {
+ List> commandTypes = commandSupplier != null
+ ? commandSupplier.getCommandClasses(null)
+ : List.of();
+ if (commandSupplier == null
+ || commandTypes.stream().anyMatch(item.getAcceptedCommandTypes()::contains)) {
insertDiscardingMembers(itemsData, item, interpretationMetadata);
if (exactMatch) {
insertDiscardingMembers(exactMatchItemsData, item, interpretationMetadata);
@@ -645,14 +785,20 @@ protected List
- getMatchingItems(ResourceBundle language, String[] labelFra
}
}
if (logger.isDebugEnabled()) {
- String typeDetails = commandType != null ? " that accept " + commandType.getSimpleName() : "";
+ List> commandTypes = commandSupplier != null
+ ? commandSupplier.getCommandClasses(null)
+ : List.of();
+ String typeDetails = !commandTypes.isEmpty()
+ ? " that accept " + commandTypes.stream().map(Class::getSimpleName).distinct()
+ .collect(Collectors.joining(" or "))
+ : "";
logger.debug("Partial matched items against {}{}: {}", labelFragments, typeDetails,
itemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", ")));
logger.debug("Exact matched items against {}{}: {}", labelFragments, typeDetails,
exactMatchItemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", ")));
}
@Nullable
- String locationContext = dialogContext != null ? dialogContext.locationItem() : null;
+ String locationContext = context.locationItem();
if (locationContext != null && itemsData.size() > 1) {
logger.debug("Filtering {} matched items based on location '{}'", itemsData.size(), locationContext);
Item matchByLocation = filterMatchedItemsByLocation(itemsData, locationContext);
@@ -724,9 +870,89 @@ protected List tokenize(Locale locale, @Nullable String text) {
return parts;
}
- @Override
- public Set getSupportedLocales() {
- return Collections.unmodifiableSet(getLanguageRules().keySet());
+ /**
+ * Parses a rule as text into a {@link Rule} instance.
+ *
+ * The rule text should be a list of space separated expressions,
+ * one of them but not the first should be the character '*' (which indicates dynamic part to capture),
+ * the other expressions can be conformed by a single word, alternative words separated by '|',
+ * and can be marked as optional by adding '?' at the end.
+ * There must be at least one non-optional expression at the beginning of the rule.
+ *
+ * An example of a valid text will be 'watch * on|at? the tv'.
+ *
+ * @param item will be the target of the rule.
+ * @param ruleText the text to parse into a {@link Rule}
+ *
+ * @return The created rule.
+ */
+ protected @Nullable Rule parseItemCustomRule(Item item, String ruleText) {
+ String[] ruleParts = ruleText.split("\\*");
+ Expression headExpression;
+ @Nullable
+ Expression tailExpression = null;
+ try {
+ if (ruleText.startsWith("*") || !ruleText.contains(" *") || ruleParts.length > 2) {
+ throw new ParseException("Incorrect usage of character '*'", 0);
+ }
+ List headExpressions = new ArrayList<>();
+ boolean headHasNonOptional = true;
+ for (String s : ruleParts[0].split("\\s")) {
+ if (!s.isBlank()) {
+ String trim = s.trim();
+ Expression expression = parseItemRuleTokenText(trim);
+ if (expression instanceof ExpressionCardinality expressionCardinality) {
+ if (!expressionCardinality.isAtLeastOne()) {
+ headHasNonOptional = false;
+ }
+ } else {
+ headHasNonOptional = false;
+ }
+ headExpressions.add(expression);
+ }
+ }
+ if (headHasNonOptional) {
+ throw new ParseException("Rule head only contains optional expressions", 0);
+ }
+ headExpression = seq(headExpressions.toArray());
+ if (ruleParts.length == 2) {
+ List tailExpressions = new ArrayList<>();
+ for (String s : ruleParts[1].split("\\s")) {
+ if (!s.isBlank()) {
+ String trim = s.trim();
+ Expression expression = parseItemRuleTokenText(trim);
+ tailExpressions.add(expression);
+ }
+ }
+ if (!tailExpressions.isEmpty()) {
+ tailExpression = seq(tailExpressions.toArray());
+ }
+ }
+ } catch (ParseException e) {
+ logger.warn("Unable to parse item {} rule '{}': {}", item.getName(), ruleText, e.getMessage());
+ return null;
+ }
+ return customItemRule(item, headExpression, tailExpression);
+ }
+
+ private Expression parseItemRuleTokenText(String tokenText) throws ParseException {
+ boolean optional = false;
+ if (tokenText.endsWith("?")) {
+ tokenText = tokenText.substring(0, tokenText.length() - 1);
+ optional = true;
+ }
+ if (tokenText.contains("?")) {
+ throw new ParseException("The character '?' can only be used at the end of the expression", 0);
+ }
+ if (tokenText.equals("|")) {
+ throw new ParseException("The character '|' can not be used alone", 0);
+ }
+ Expression expression = seq(tokenText.contains("|") ? alt(Arrays.stream(tokenText.split("\\|"))//
+ .filter((s) -> !s.isBlank()).toArray()) : tokenText);
+ if (optional) {
+ return opt(expression);
+ }
+ return expression;
}
@Override
@@ -972,4 +1198,30 @@ private static class ItemInterpretationMetadata {
ItemInterpretationMetadata() {
}
}
+
+ protected interface ItemCommandSupplier {
+ @Nullable
+ Command getItemCommand(Item item);
+
+ String getCommandLabel();
+
+ List> getCommandClasses(@Nullable Item item);
+ }
+
+ private record SingleCommandSupplier(Command command) implements ItemCommandSupplier {
+ @Override
+ public @Nullable Command getItemCommand(Item ignored) {
+ return command;
+ }
+
+ @Override
+ public String getCommandLabel() {
+ return command.toFullString();
+ }
+
+ @Override
+ public List> getCommandClasses(@Nullable Item ignored) {
+ return List.of(command.getClass());
+ }
+ }
}
diff --git a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/Rule.java b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/Rule.java
index 97e20530fae..20a9b5185b9 100644
--- a/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/Rule.java
+++ b/bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/text/Rule.java
@@ -12,11 +12,11 @@
*/
package org.openhab.core.voice.text;
+import java.util.List;
import java.util.ResourceBundle;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.core.voice.DialogContext;
/**
* Represents an expression plus action code that will be executed after successful parsing. This class is immutable and
@@ -27,15 +27,18 @@
@NonNullByDefault
public abstract class Rule {
- private Expression expression;
+ private final Expression expression;
+ private final List allowedItemNames;
/**
* Constructs a new instance.
*
- * @param expression the expression that has to parse successfully, before {@link interpretAST} is called
+ * @param expression the expression that has to parse successfully, before {@link #interpretAST} is called
+ * @param allowedItemNames List of allowed items or empty for disabled.
*/
- public Rule(Expression expression) {
+ public Rule(Expression expression, List allowedItemNames) {
this.expression = expression;
+ this.allowedItemNames = allowedItemNames;
}
/**
@@ -43,15 +46,16 @@ public Rule(Expression expression) {
*
* @param language a resource bundle that can be used for looking up common localized response phrases
* @param node the resulting AST node of the parse run. To be used as input.
+ * @param context for rule interpretation
* @return
*/
public abstract InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
- @Nullable DialogContext dialogContext);
+ InterpretationContext context);
- InterpretationResult execute(ResourceBundle language, TokenList list, @Nullable DialogContext dialogContext) {
+ InterpretationResult execute(ResourceBundle language, TokenList list, @Nullable String locationItem) {
ASTNode node = expression.parse(language, list);
if (node.isSuccess() && node.getRemainingTokens().eof()) {
- return interpretAST(language, node, dialogContext);
+ return interpretAST(language, node, new InterpretationContext(this.allowedItemNames, locationItem));
}
return InterpretationResult.SYNTAX_ERROR;
}
@@ -62,4 +66,15 @@ InterpretationResult execute(ResourceBundle language, TokenList list, @Nullable
public Expression getExpression() {
return expression;
}
+
+ /**
+ * Context for rule execution.
+ *
+ * @author Miguel Álvarez - Initial contribution
+ *
+ * @param allowedItems List of item names to restrict rule compatibility to, empty for disabled.
+ * @param locationItem Location item to prioritize item matches or null.
+ */
+ public record InterpretationContext(List allowedItems, @Nullable String locationItem) {
+ }
}
diff --git a/bundles/org.openhab.core.voice/src/test/java/org/openhab/core/voice/internal/text/StandardInterpreterTest.java b/bundles/org.openhab.core.voice/src/test/java/org/openhab/core/voice/internal/text/StandardInterpreterTest.java
index 910c7c5f777..93ef3dcba3e 100644
--- a/bundles/org.openhab.core.voice/src/test/java/org/openhab/core/voice/internal/text/StandardInterpreterTest.java
+++ b/bundles/org.openhab.core.voice/src/test/java/org/openhab/core/voice/internal/text/StandardInterpreterTest.java
@@ -23,6 +23,7 @@
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -39,8 +40,14 @@
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.items.events.ItemEventFactory;
+import org.openhab.core.library.items.DimmerItem;
+import org.openhab.core.library.items.StringItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.CommandDescription;
+import org.openhab.core.types.CommandOption;
import org.openhab.core.voice.DialogContext;
import org.openhab.core.voice.STTService;
import org.openhab.core.voice.TTSService;
@@ -129,6 +136,7 @@ public void allowUseItemSynonyms() throws InterpretationException {
MetadataKey computerMetadataKey = new MetadataKey("synonyms", computerItem.getName());
when(metadataRegistryMock.get(computerMetadataKey))
.thenReturn(new Metadata(computerMetadataKey, "PC,Bedroom PC", null));
+ when(metadataRegistryMock.get(new MetadataKey("voice-system", computerItem.getName()))).thenReturn(null);
List
- items = List.of(computerItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "turn off computer"));
@@ -143,4 +151,88 @@ public void allowUseItemSynonyms() throws InterpretationException {
verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(computerItem.getName(), OnOffType.OFF));
}
+
+ @Test
+ public void allowUseItemDescription() throws InterpretationException {
+ var cmdDescription = new CommandDescription() {
+ @Override
+ public List getCommandOptions() {
+ return List.of(new CommandOption("10", "low"), new CommandOption("50", "medium"),
+ new CommandOption("90", "high"), new CommandOption("100", "high two"));
+ }
+ };
+ var brightness = new DimmerItem("brightness") {
+ @Override
+ public @Nullable CommandDescription getCommandDescription() {
+ return cmdDescription;
+ }
+
+ @Override
+ public @Nullable CommandDescription getCommandDescription(@Nullable Locale locale) {
+ return getCommandDescription();
+ }
+ };
+ brightness.setLabel("Brightness");
+ List
- items = List.of(brightness);
+ when(itemRegistryMock.getItems()).thenReturn(items);
+ assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "set the brightness to low"));
+ verify(eventPublisherMock, times(1))
+ .post(ItemEventFactory.createCommandEvent(brightness.getName(), new PercentType(10)));
+ reset(eventPublisherMock);
+ assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "set brightness to medium"));
+ verify(eventPublisherMock, times(1))
+ .post(ItemEventFactory.createCommandEvent(brightness.getName(), new PercentType(50)));
+ reset(eventPublisherMock);
+ assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "set brightness high"));
+ verify(eventPublisherMock, times(1))
+ .post(ItemEventFactory.createCommandEvent(brightness.getName(), new PercentType(90)));
+ reset(eventPublisherMock);
+ assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "set brightness high two"));
+ verify(eventPublisherMock, times(1))
+ .post(ItemEventFactory.createCommandEvent(brightness.getName(), new PercentType(100)));
+ }
+
+ @Test
+ public void allowUseCustomCommands() throws InterpretationException {
+ var virtualItem = new StringItem("virtual");
+ MetadataKey voiceMetadataKey = new MetadataKey("voice-system", virtualItem.getName());
+ when(metadataRegistryMock.get(voiceMetadataKey))
+ .thenReturn(new Metadata(voiceMetadataKey, "watch|play * on|at? the? tv", null));
+ List
- items = List.of(virtualItem);
+ when(itemRegistryMock.getItems()).thenReturn(items);
+ assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
+ verify(eventPublisherMock, times(1))
+ .post(ItemEventFactory.createCommandEvent(virtualItem.getName(), new StringType("channel 4")));
+ reset(eventPublisherMock);
+ }
+
+ @Test
+ public void allowUseItemDescriptionOnCustomCommands() throws InterpretationException {
+ var cmdDescription = new CommandDescription() {
+ @Override
+ public List getCommandOptions() {
+ return List.of(new CommandOption("KEY_4", "channel 4"));
+ }
+ };
+ var virtualItem = new StringItem("virtual") {
+ @Override
+ public @Nullable CommandDescription getCommandDescription() {
+ return cmdDescription;
+ }
+
+ @Override
+ public @Nullable CommandDescription getCommandDescription(@Nullable Locale locale) {
+ return getCommandDescription();
+ }
+ };
+ MetadataKey voiceMetadataKey = new MetadataKey("voice-system", virtualItem.getName());
+ when(metadataRegistryMock.get(voiceMetadataKey))
+ .thenReturn(new Metadata(voiceMetadataKey, "watch|play * on|at? the? tv", null));
+ List
- items = List.of(virtualItem);
+ when(itemRegistryMock.getItems()).thenReturn(items);
+ assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
+ verify(eventPublisherMock, times(1))
+ .post(ItemEventFactory.createCommandEvent(virtualItem.getName(), new StringType("KEY_4")));
+ reset(eventPublisherMock);
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ItemUpdater.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ItemUpdater.java
index 3b5db162036..6d41ed7962a 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ItemUpdater.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/ItemUpdater.java
@@ -22,6 +22,7 @@
import org.openhab.core.items.events.AbstractItemEventSubscriber;
import org.openhab.core.items.events.ItemCommandEvent;
import org.openhab.core.items.events.ItemStateEvent;
+import org.openhab.core.items.events.ItemTimeSeriesEvent;
import org.openhab.core.types.State;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@@ -96,4 +97,16 @@ protected void receiveCommand(ItemCommandEvent commandEvent) {
logger.debug("Received command for non-existing item: {}", e.getMessage());
}
}
+
+ @Override
+ protected void receiveTimeSeries(ItemTimeSeriesEvent timeSeriesEvent) {
+ try {
+ Item item = itemRegistry.getItem(timeSeriesEvent.getItemName());
+ if (!(item instanceof GroupItem) && item instanceof GenericItem genericItem) {
+ genericItem.setTimeSeries(timeSeriesEvent.getTimeSeries());
+ }
+ } catch (ItemNotFoundException e) {
+ logger.debug("Received command for non-existing item: {}", e.getMessage());
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/GenericItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/GenericItem.java
index 1582f93a449..e6f4d062639 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/GenericItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/GenericItem.java
@@ -39,6 +39,7 @@
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.StateDescription;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -51,6 +52,7 @@
* @author Kai Kreuzer - Initial contribution
* @author Andre Fuechsel - Added tags
* @author Stefan Bußweiler - Migration to new ESH event concept
+ * @author Jan N. Klug - Added time series support
*/
@NonNullByDefault
public abstract class GenericItem implements ActiveItem {
@@ -64,6 +66,9 @@ public abstract class GenericItem implements ActiveItem {
protected Set listeners = new CopyOnWriteArraySet<>(
Collections.newSetFromMap(new WeakHashMap<>()));
+ protected Set timeSeriesListeners = new CopyOnWriteArraySet<>(
+ Collections.newSetFromMap(new WeakHashMap<>()));
+
protected List groupNames = new ArrayList<>();
protected Set tags = new HashSet<>();
@@ -229,6 +234,50 @@ protected final void applyState(State state) {
}
}
+ /**
+ * Set a new time series.
+ *
+ * Subclasses may override this method in order to do necessary conversions upfront. Afterwards,
+ * {@link #applyTimeSeries(TimeSeries)} should be called by classes overriding this method.
+ *
+ * A time series may only contain events that are compatible with the item's internal state.
+ *
+ * @param timeSeries new time series of this item
+ */
+ public void setTimeSeries(TimeSeries timeSeries) {
+ applyTimeSeries(timeSeries);
+ }
+
+ /**
+ * Sets new time series, notifies listeners and sends events.
+ *
+ * Classes overriding the {@link #setTimeSeries(TimeSeries)} method should call this method in order to actually set
+ * the time series, inform listeners and send the event.
+ *
+ * A time series may only contain events that are compatible with the item's internal state.
+ *
+ * @param timeSeries new time series of this item
+ */
+ protected final void applyTimeSeries(TimeSeries timeSeries) {
+ // notify listeners
+ Set clonedListeners = new CopyOnWriteArraySet<>(timeSeriesListeners);
+ ExecutorService pool = ThreadPoolManager.getPool(ITEM_THREADPOOLNAME);
+ clonedListeners.forEach(listener -> pool.execute(() -> {
+ try {
+ listener.timeSeriesUpdated(GenericItem.this, timeSeries);
+ } catch (Exception e) {
+ logger.warn("failed notifying listener '{}' about timeseries update of item {}: {}", listener,
+ GenericItem.this.getName(), e.getMessage(), e);
+ }
+ }));
+
+ // send event
+ EventPublisher eventPublisher1 = this.eventPublisher;
+ if (eventPublisher1 != null) {
+ eventPublisher1.post(ItemEventFactory.createTimeSeriesUpdatedEvent(this.name, timeSeries, null));
+ }
+ }
+
private void sendStateUpdatedEvent(State newState) {
EventPublisher eventPublisher1 = this.eventPublisher;
if (eventPublisher1 != null) {
@@ -314,6 +363,18 @@ public void removeStateChangeListener(StateChangeListener listener) {
}
}
+ public void addTimeSeriesListener(TimeSeriesListener listener) {
+ synchronized (timeSeriesListeners) {
+ timeSeriesListeners.add(listener);
+ }
+ }
+
+ public void removeTimeSeriesListener(TimeSeriesListener listener) {
+ synchronized (timeSeriesListeners) {
+ timeSeriesListeners.remove(listener);
+ }
+ }
+
@Override
public int hashCode() {
final int prime = 31;
@@ -437,8 +498,7 @@ public void setCategory(@Nullable String category) {
* @return true if state is an acceptedDataType or subclass thereof
*/
public boolean isAcceptedState(List> acceptedDataTypes, State state) {
- return acceptedDataTypes.stream().map(clazz -> clazz.isAssignableFrom(state.getClass())).filter(found -> found)
- .findAny().isPresent();
+ return acceptedDataTypes.stream().anyMatch(clazz -> clazz.isAssignableFrom(state.getClass()));
}
protected void logSetTypeError(State state) {
@@ -446,7 +506,12 @@ protected void logSetTypeError(State state) {
state.getClass().getSimpleName(), getName(), getClass().getSimpleName());
}
- private @Nullable CommandDescription stateOptions2CommandOptions(StateDescription stateDescription) {
+ protected void logSetTypeError(TimeSeries timeSeries) {
+ logger.error("Tried to set invalid state in time series {} on item {} of type {}, ignoring it", timeSeries,
+ getName(), getClass().getSimpleName());
+ }
+
+ private CommandDescription stateOptions2CommandOptions(StateDescription stateDescription) {
CommandDescriptionBuilder builder = CommandDescriptionBuilder.create();
stateDescription.getOptions()
.forEach(so -> builder.withCommandOption(new CommandOption(so.getValue(), so.getLabel())));
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/TimeSeriesListener.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/TimeSeriesListener.java
new file mode 100644
index 00000000000..dbc44f8668e
--- /dev/null
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/TimeSeriesListener.java
@@ -0,0 +1,38 @@
+/**
+ * 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.items;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ *
+ * This interface must be implemented by all classes that want to be notified about |@link TimeSeries} updates of an
+ * item.
+ *
+ *
+ * The {@link GenericItem} class provides the possibility to register such listeners.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public interface TimeSeriesListener {
+
+ /**
+ * This method is called, if a time series update was sent to the item.
+ *
+ * @param item the item the timeseries was updated for
+ * @param timeSeries the time series
+ */
+ void timeSeriesUpdated(Item item, TimeSeries timeSeries);
+}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/AbstractItemEventSubscriber.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/AbstractItemEventSubscriber.java
index e9f323fc991..f3124152fd6 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/AbstractItemEventSubscriber.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/AbstractItemEventSubscriber.java
@@ -31,7 +31,8 @@
@NonNullByDefault
public abstract class AbstractItemEventSubscriber implements EventSubscriber {
- private final Set subscribedEventTypes = Set.of(ItemStateEvent.TYPE, ItemCommandEvent.TYPE);
+ private final Set subscribedEventTypes = Set.of(ItemStateEvent.TYPE, ItemCommandEvent.TYPE,
+ ItemTimeSeriesEvent.TYPE);
@Override
public Set getSubscribedEventTypes() {
@@ -44,6 +45,8 @@ public void receive(Event event) {
receiveUpdate(stateEvent);
} else if (event instanceof ItemCommandEvent commandEvent) {
receiveCommand(commandEvent);
+ } else if (event instanceof ItemTimeSeriesEvent timeSeriesEvent) {
+ receiveTimeSeries(timeSeriesEvent);
}
}
@@ -66,4 +69,14 @@ protected void receiveUpdate(ItemStateEvent updateEvent) {
// Default implementation: do nothing.
// Can be implemented by subclass in order to handle item updates.
}
+
+ /**
+ * Callback method for receiving item timeseries events from the openHAB event bus.
+ *
+ * @param timeSeriesEvent the timeseries event
+ */
+ protected void receiveTimeSeries(ItemTimeSeriesEvent timeSeriesEvent) {
+ // Default implementation: do nothing.
+ // Can be implemented by subclass in order to handle timeseries updates.
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemEventFactory.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemEventFactory.java
index 5f4bdbc460d..6225c4c7490 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemEventFactory.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemEventFactory.java
@@ -14,6 +14,7 @@
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.time.Instant;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@@ -29,6 +30,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.Type;
import org.openhab.core.types.UnDefType;
import org.osgi.service.component.annotations.Component;
@@ -51,6 +53,8 @@ public class ItemEventFactory extends AbstractEventFactory {
private static final String ITEM_STATE_EVENT_TOPIC = "openhab/items/{itemName}/state";
private static final String ITEM_STATE_UPDATED_EVENT_TOPIC = "openhab/items/{itemName}/stateupdated";
+ private static final String ITEM_TIME_SERIES_EVENT_TOPIC = "openhab/items/{itemName}/timeseries";
+ private static final String ITEM_TIME_SERIES_UPDATED_EVENT_TOPIC = "openhab/items/{itemName}/timeseriesupdated";
private static final String ITEM_STATE_PREDICTED_EVENT_TOPIC = "openhab/items/{itemName}/statepredicted";
@@ -72,7 +76,8 @@ public class ItemEventFactory extends AbstractEventFactory {
public ItemEventFactory() {
super(Set.of(ItemCommandEvent.TYPE, ItemStateEvent.TYPE, ItemStatePredictedEvent.TYPE,
ItemStateUpdatedEvent.TYPE, ItemStateChangedEvent.TYPE, ItemAddedEvent.TYPE, ItemUpdatedEvent.TYPE,
- ItemRemovedEvent.TYPE, GroupStateUpdatedEvent.TYPE, GroupItemStateChangedEvent.TYPE));
+ ItemRemovedEvent.TYPE, GroupStateUpdatedEvent.TYPE, GroupItemStateChangedEvent.TYPE,
+ ItemTimeSeriesEvent.TYPE, ItemTimeSeriesUpdatedEvent.TYPE));
}
@Override
@@ -88,6 +93,10 @@ protected Event createEventByType(String eventType, String topic, String payload
return createStateUpdatedEvent(topic, payload);
} else if (ItemStateChangedEvent.TYPE.equals(eventType)) {
return createStateChangedEvent(topic, payload);
+ } else if (ItemTimeSeriesEvent.TYPE.equals(eventType)) {
+ return createTimeSeriesEvent(topic, payload);
+ } else if (ItemTimeSeriesUpdatedEvent.TYPE.equals(eventType)) {
+ return createTimeSeriesUpdatedEvent(topic, payload);
} else if (ItemAddedEvent.TYPE.equals(eventType)) {
return createAddedEvent(topic, payload);
} else if (ItemUpdatedEvent.TYPE.equals(eventType)) {
@@ -155,6 +164,20 @@ private Event createStateChangedEvent(String topic, String payload) {
return new ItemStateChangedEvent(topic, payload, itemName, state, oldState);
}
+ private Event createTimeSeriesEvent(String topic, String payload) {
+ String itemName = getItemName(topic);
+ ItemTimeSeriesEventPayloadBean bean = deserializePayload(payload, ItemTimeSeriesEventPayloadBean.class);
+ TimeSeries timeSeries = bean.getTimeSeries();
+ return new ItemTimeSeriesEvent(topic, payload, itemName, timeSeries, null);
+ }
+
+ private Event createTimeSeriesUpdatedEvent(String topic, String payload) {
+ String itemName = getItemName(topic);
+ ItemTimeSeriesEventPayloadBean bean = deserializePayload(payload, ItemTimeSeriesEventPayloadBean.class);
+ TimeSeries timeSeries = bean.getTimeSeries();
+ return new ItemTimeSeriesUpdatedEvent(topic, payload, itemName, timeSeries, null);
+ }
+
private State getState(String type, String value) {
return parseType(type, value, State.class);
}
@@ -175,7 +198,7 @@ private String getMemberName(String topic) {
return topicElements[3];
}
- private T parseType(String typeName, String valueToParse, Class desiredClass) {
+ private static T parseType(String typeName, String valueToParse, Class desiredClass) {
Object parsedObject = null;
String simpleClassName = typeName + TYPE_POSTFIX;
parsedObject = parseSimpleClassName(simpleClassName, valueToParse);
@@ -190,7 +213,7 @@ private T parseType(String typeName, String valueToParse, Class desiredCl
return desiredClass.cast(parsedObject);
}
- private @Nullable Object parseSimpleClassName(String simpleClassName, String valueToParse) {
+ private static @Nullable Object parseSimpleClassName(String simpleClassName, String valueToParse) {
if (simpleClassName.equals(UnDefType.class.getSimpleName())) {
return UnDefType.valueOf(valueToParse);
}
@@ -320,6 +343,22 @@ public static ItemStateUpdatedEvent createStateUpdatedEvent(String itemName, Sta
return new ItemStateUpdatedEvent(topic, payload, itemName, state, source);
}
+ public static ItemTimeSeriesEvent createTimeSeriesEvent(String itemName, TimeSeries timeSeries,
+ @Nullable String source) {
+ String topic = buildTopic(ITEM_TIME_SERIES_EVENT_TOPIC, itemName);
+ ItemTimeSeriesEventPayloadBean bean = new ItemTimeSeriesEventPayloadBean(timeSeries);
+ String payload = serializePayload(bean);
+ return new ItemTimeSeriesEvent(topic, payload, itemName, timeSeries, source);
+ }
+
+ public static ItemTimeSeriesUpdatedEvent createTimeSeriesUpdatedEvent(String itemName, TimeSeries timeSeries,
+ @Nullable String source) {
+ String topic = buildTopic(ITEM_TIME_SERIES_UPDATED_EVENT_TOPIC, itemName);
+ ItemTimeSeriesEventPayloadBean bean = new ItemTimeSeriesEventPayloadBean(timeSeries);
+ String payload = serializePayload(bean);
+ return new ItemTimeSeriesUpdatedEvent(topic, payload, itemName, timeSeries, source);
+ }
+
/**
* Creates a group item state updated event.
*
@@ -585,4 +624,58 @@ public String getOldValue() {
return oldValue;
}
}
+
+ private static class ItemTimeSeriesEventPayloadBean {
+ private @NonNullByDefault({}) List timeSeries;
+ private @NonNullByDefault({}) String policy;
+
+ @SuppressWarnings("unused")
+ private ItemTimeSeriesEventPayloadBean() {
+ // do not remove, GSON needs it
+ }
+
+ public ItemTimeSeriesEventPayloadBean(TimeSeries timeSeries) {
+ this.timeSeries = timeSeries.getStates().map(TimeSeriesPayload::new).toList();
+ this.policy = timeSeries.getPolicy().name();
+ }
+
+ public TimeSeries getTimeSeries() {
+ TimeSeries timeSeries1 = new TimeSeries(TimeSeries.Policy.valueOf(policy));
+ timeSeries.forEach(e -> {
+ State state = parseType(e.getType(), e.getValue(), State.class);
+ Instant instant = Instant.parse(e.getTimestamp());
+ timeSeries1.add(instant, state);
+ });
+ return timeSeries1;
+ }
+
+ private static class TimeSeriesPayload {
+ private @NonNullByDefault({}) String type;
+ private @NonNullByDefault({}) String value;
+ private @NonNullByDefault({}) String timestamp;
+
+ @SuppressWarnings("unused")
+ private TimeSeriesPayload() {
+ // do not remove, GSON needs it
+ }
+
+ public TimeSeriesPayload(TimeSeries.Entry entry) {
+ type = getStateType(entry.state());
+ value = entry.state().toFullString();
+ timestamp = entry.timestamp().toString();
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public String getTimestamp() {
+ return timestamp;
+ }
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemTimeSeriesEvent.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemTimeSeriesEvent.java
new file mode 100644
index 00000000000..2b8e0162efe
--- /dev/null
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemTimeSeriesEvent.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.items.events;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link ItemTimeSeriesEvent} can be used to report item time series updates through the openHAB event bus.
+ * Time series events must be created with the {@link ItemEventFactory}.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class ItemTimeSeriesEvent extends ItemEvent {
+
+ public static final String TYPE = ItemTimeSeriesEvent.class.getSimpleName();
+
+ protected final TimeSeries timeSeries;
+
+ /**
+ * Constructs a new item time series event.
+ *
+ * @param topic the topic
+ * @param payload the payload
+ * @param itemName the item name
+ * @param timeSeries the time series
+ * @param source the source, can be null
+ */
+ protected ItemTimeSeriesEvent(String topic, String payload, String itemName, TimeSeries timeSeries,
+ @Nullable String source) {
+ super(topic, payload, itemName, source);
+ this.timeSeries = timeSeries;
+ }
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ /**
+ * Gets the item time series.
+ *
+ * @return the item time series
+ */
+ public TimeSeries getTimeSeries() {
+ return timeSeries;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Item '%s' shall process timeseries %s", itemName, timeSeries.getStates().toList());
+ }
+}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemTimeSeriesUpdatedEvent.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemTimeSeriesUpdatedEvent.java
new file mode 100644
index 00000000000..de3419d267a
--- /dev/null
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/items/events/ItemTimeSeriesUpdatedEvent.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.items.events;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link ItemTimeSeriesUpdatedEvent} can be used to report item time series updates through the openHAB event bus.
+ * Time series events must be created with the {@link ItemEventFactory}.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class ItemTimeSeriesUpdatedEvent extends ItemEvent {
+
+ public static final String TYPE = ItemTimeSeriesUpdatedEvent.class.getSimpleName();
+
+ protected final TimeSeries timeSeries;
+
+ /**
+ * Constructs a new item time series updated event.
+ *
+ * @param topic the topic
+ * @param payload the payload
+ * @param itemName the item name
+ * @param timeSeries the time series
+ * @param source the source, can be null
+ */
+ protected ItemTimeSeriesUpdatedEvent(String topic, String payload, String itemName, TimeSeries timeSeries,
+ @Nullable String source) {
+ super(topic, payload, itemName, source);
+ this.timeSeries = timeSeries;
+ }
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ /**
+ * Gets the item time series.
+ *
+ * @return the item time series
+ */
+ public TimeSeries getTimeSeries() {
+ return timeSeries;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Item '%s' updated timeseries %s", itemName, timeSeries.getStates().toList());
+ }
+}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/CallItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/CallItem.java
index 73c87b9a9f6..e6197ef749d 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/CallItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/CallItem.java
@@ -21,6 +21,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -53,9 +54,18 @@ public List> getAcceptedCommandTypes() {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> isAcceptedState(ACCEPTED_DATA_TYPES, s.state()))) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ColorItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ColorItem.java
index 45afe755309..fa2beba2039 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ColorItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ColorItem.java
@@ -25,6 +25,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -92,4 +93,13 @@ public void setState(State state) {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof HSBType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ContactItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ContactItem.java
index e64cc907cdd..843043d8b29 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ContactItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ContactItem.java
@@ -21,6 +21,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -53,9 +54,18 @@ public List> getAcceptedCommandTypes() {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof OpenClosedType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DateTimeItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DateTimeItem.java
index 3c4ccfa3ddc..1556d32cac2 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DateTimeItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DateTimeItem.java
@@ -21,6 +21,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -69,4 +70,13 @@ public void setState(State state) {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof DateTimeType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DimmerItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DimmerItem.java
index a3cf6f0ec5e..898fbca1bb6 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DimmerItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/DimmerItem.java
@@ -22,6 +22,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -79,4 +80,13 @@ public void setState(State state) {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof PercentType)) {
+ super.applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ImageItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ImageItem.java
index b2a830c2a61..c0567f2e85b 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ImageItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/ImageItem.java
@@ -21,6 +21,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -51,9 +52,18 @@ public List> getAcceptedCommandTypes() {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof RawType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/LocationItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/LocationItem.java
index 3d8f691e7fe..b01601597d7 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/LocationItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/LocationItem.java
@@ -23,6 +23,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -75,9 +76,18 @@ public DecimalType distanceFrom(@Nullable LocationItem awayItem) {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof PointType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/NumberItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/NumberItem.java
index e7218508092..15be66124a2 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/NumberItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/NumberItem.java
@@ -14,6 +14,7 @@
import java.util.List;
import java.util.Locale;
+import java.util.Objects;
import javax.measure.Dimension;
import javax.measure.Quantity;
@@ -35,6 +36,7 @@
import org.openhab.core.types.State;
import org.openhab.core.types.StateDescription;
import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
import org.openhab.core.types.util.UnitUtils;
import org.slf4j.Logger;
@@ -136,13 +138,12 @@ public void send(QuantityType> command) {
return dimension;
}
- @Override
- public void setState(State state) {
+ private @Nullable State getInternalState(State state) {
if (state instanceof QuantityType> quantityType) {
if (dimension == null) {
// QuantityType update to a NumberItem without unit, strip unit
DecimalType plainState = new DecimalType(quantityType.toBigDecimal());
- super.applyState(plainState);
+ return plainState;
} else {
// QuantityType update to a NumberItem with unit, convert to item unit (if possible)
Unit> stateUnit = quantityType.getUnit();
@@ -150,7 +151,7 @@ public void setState(State state) {
? quantityType.toInvertibleUnit(unit)
: null;
if (convertedState != null) {
- super.applyState(convertedState);
+ return convertedState;
} else {
logger.warn("Failed to update item '{}' because '{}' could not be converted to the item unit '{}'",
name, state, unit);
@@ -159,18 +160,44 @@ public void setState(State state) {
} else if (state instanceof DecimalType decimalType) {
if (dimension == null) {
// DecimalType update to NumberItem with unit
- super.applyState(decimalType);
+ return decimalType;
} else {
// DecimalType update for a NumberItem with dimension, convert to QuantityType
- super.applyState(new QuantityType<>(decimalType.doubleValue(), unit));
+ return new QuantityType<>(decimalType.doubleValue(), unit);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void setState(State state) {
+ if (state instanceof DecimalType || state instanceof QuantityType>) {
+ State internalState = getInternalState(state);
+ if (internalState != null) {
+ applyState(internalState);
}
} else if (state instanceof UnDefType) {
- super.applyState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ TimeSeries internalSeries = new TimeSeries(timeSeries.getPolicy());
+ timeSeries.getStates().forEach(s -> internalSeries.add(s.timestamp(),
+ Objects.requireNonNullElse(getInternalState(s.state()), UnDefType.NULL)));
+
+ if (dimension != null && internalSeries.getStates().allMatch(s -> s.state() instanceof QuantityType>)) {
+ applyTimeSeries(internalSeries);
+ } else if (internalSeries.getStates().allMatch(s -> s.state() instanceof DecimalType)) {
+ applyTimeSeries(internalSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
+
/**
* Returns the optional unit symbol for this {@link NumberItem}.
*
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/PlayerItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/PlayerItem.java
index a1783e779dc..e2d6c4487e2 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/PlayerItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/PlayerItem.java
@@ -23,6 +23,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -71,9 +72,19 @@ public void send(NextPreviousType command) {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates()
+ .allMatch(s -> s.state() instanceof PlayPauseType || s.state() instanceof RewindFastforwardType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/RollershutterItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/RollershutterItem.java
index c8e91477df6..c838b5c5348 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/RollershutterItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/RollershutterItem.java
@@ -23,6 +23,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -80,4 +81,13 @@ public void setState(State state) {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> isAcceptedState(ACCEPTED_DATA_TYPES, s.state()))) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/StringItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/StringItem.java
index 6c5a960afec..da2650b45b9 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/StringItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/StringItem.java
@@ -24,6 +24,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.TypeParser;
import org.openhab.core.types.UnDefType;
@@ -76,9 +77,18 @@ public List> getAcceptedCommandTypes() {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof StringType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/SwitchItem.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/SwitchItem.java
index d24c1d05fd0..37ae2258a99 100644
--- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/SwitchItem.java
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/items/SwitchItem.java
@@ -21,6 +21,7 @@
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
import org.openhab.core.types.UnDefType;
/**
@@ -61,9 +62,18 @@ public List> getAcceptedCommandTypes() {
@Override
public void setState(State state) {
if (isAcceptedState(ACCEPTED_DATA_TYPES, state)) {
- super.setState(state);
+ applyState(state);
} else {
logSetTypeError(state);
}
}
+
+ @Override
+ public void setTimeSeries(TimeSeries timeSeries) {
+ if (timeSeries.getStates().allMatch(s -> s.state() instanceof OnOffType)) {
+ applyTimeSeries(timeSeries);
+ } else {
+ logSetTypeError(timeSeries);
+ }
+ }
}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/types/TimeSeries.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/types/TimeSeries.java
new file mode 100644
index 00000000000..709f5ff861f
--- /dev/null
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/types/TimeSeries.java
@@ -0,0 +1,124 @@
+/**
+ * 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.types;
+
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.TreeSet;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link TimeSeries} is used to transport a set of states together with their timestamp.
+ * It can be used for persisting historic state or forecasts.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class TimeSeries {
+ private final TreeSet states = new TreeSet<>(Comparator.comparing(e -> e.timestamp));
+ private final Policy policy;
+
+ public TimeSeries(Policy policy) {
+ this.policy = policy;
+ }
+
+ /**
+ * Get the persistence policy of this series.
+ *
+ * {@link Policy#ADD} add the content to the persistence, {@link Policy#REPLACE} first removes all persisted
+ * elements in the timespan given by {@link #getBegin()} and {@link #getEnd()}.
+ *
+ * @return
+ */
+ public Policy getPolicy() {
+ return policy;
+ }
+
+ /**
+ * Get the timestamp of the first element in this series.
+ *
+ * @return the {@link Instant} of the first element
+ */
+ public Instant getBegin() {
+ return states.isEmpty() ? Instant.MAX : states.first().timestamp();
+ }
+
+ /**
+ * Get the timestamp of the last element in this series.
+ *
+ * @return the {@link Instant} of the last element
+ */
+ public Instant getEnd() {
+ return states.isEmpty() ? Instant.MIN : states.last().timestamp();
+ }
+
+ /**
+ * Get the number of elements in this series.
+ *
+ * @return the number of elements
+ */
+ public int size() {
+ return states.size();
+ }
+
+ /**
+ * Add a new element to this series.
+ *
+ * Elements can be added in an arbitrary order and are sorted chronologically.
+ *
+ * @param timestamp an {@link Instant} for the given state
+ * @param state the {@link State} at the given timestamp
+ */
+ public void add(Instant timestamp, State state) {
+ states.add(new Entry(timestamp, state));
+ }
+
+ /**
+ * Get the content of this series.
+ *
+ * The entries are returned in chronological order, earlier entries before later entries.
+ *
+ * @return a {@link } with the content of this series.
+ */
+ public Stream getStates() {
+ return List.copyOf(states).stream();
+ }
+
+ public record Entry(Instant timestamp, State state) {
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o)
+ return true;
+ if (o == null || getClass() != o.getClass())
+ return false;
+ TimeSeries that = (TimeSeries) o;
+ return Objects.equals(states, that.states) && policy == that.policy;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(states, policy);
+ }
+
+ public enum Policy {
+ ADD,
+ REPLACE
+ }
+}
diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/types/TimeSeriesTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/types/TimeSeriesTest.java
new file mode 100644
index 00000000000..d4aaef3176b
--- /dev/null
+++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/types/TimeSeriesTest.java
@@ -0,0 +1,66 @@
+/**
+ * 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.types;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.is;
+
+import java.time.Instant;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.core.library.types.DecimalType;
+
+/**
+ * The {@link TimeSeriesTest} contains tests for {@link TimeSeries}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class TimeSeriesTest {
+
+ @Test
+ public void testAdditionOrderDoesNotMatter() {
+ Instant time1 = Instant.now();
+ Instant time2 = time1.plusSeconds(1000);
+ Instant time3 = time1.minusSeconds(1000);
+ Instant time4 = time1.plusSeconds(50);
+
+ TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.ADD);
+ assertThat(timeSeries.getPolicy(), is(TimeSeries.Policy.ADD));
+
+ timeSeries.add(time1, new DecimalType(time1.toEpochMilli()));
+ timeSeries.add(time2, new DecimalType(time2.toEpochMilli()));
+ timeSeries.add(time3, new DecimalType(time3.toEpochMilli()));
+ timeSeries.add(time4, new DecimalType(time4.toEpochMilli()));
+
+ assertThat(timeSeries.size(), is(4));
+
+ // assert begin end time
+ assertThat(timeSeries.getBegin(), is(time3));
+ assertThat(timeSeries.getEnd(), is(time2));
+
+ // assert order of events and content
+ List entries = timeSeries.getStates().toList();
+ for (int i = 0; i < entries.size(); i++) {
+ if (i > 0) {
+ // assert order
+ assertThat(entries.get(i).timestamp(), is(greaterThan(entries.get(i - 1).timestamp())));
+ }
+ assertThat(entries.get(i).timestamp().toEpochMilli(),
+ is(entries.get(i).state().as(DecimalType.class).longValue()));
+ }
+ }
+}
diff --git a/itests/org.openhab.core.tests/src/main/java/org/openhab/core/internal/items/ItemUpdaterOSGiTest.java b/itests/org.openhab.core.tests/src/main/java/org/openhab/core/internal/items/ItemUpdaterOSGiTest.java
index e6e06fb3006..e24ad0aca14 100644
--- a/itests/org.openhab.core.tests/src/main/java/org/openhab/core/internal/items/ItemUpdaterOSGiTest.java
+++ b/itests/org.openhab.core.tests/src/main/java/org/openhab/core/internal/items/ItemUpdaterOSGiTest.java
@@ -14,11 +14,13 @@
import static org.junit.jupiter.api.Assertions.*;
+import java.time.Instant;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
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.openhab.core.events.Event;
@@ -28,9 +30,13 @@
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.events.ItemEventFactory;
import org.openhab.core.items.events.ItemStateChangedEvent;
+import org.openhab.core.items.events.ItemStateUpdatedEvent;
+import org.openhab.core.items.events.ItemTimeSeriesUpdatedEvent;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.test.java.JavaOSGiTest;
+import org.openhab.core.types.TimeSeries;
+import org.openhab.core.types.UnDefType;
/**
* The {@link ItemUpdaterOSGiTest} runs inside an OSGi container and tests the {@link ItemRegistry}.
@@ -44,6 +50,8 @@ public class ItemUpdaterOSGiTest extends JavaOSGiTest {
private @NonNullByDefault({}) EventPublisher eventPublisher;
private @NonNullByDefault({}) ItemRegistry itemRegistry;
+ private @NonNullByDefault({}) SwitchItem switchItem;
+
private final Queue receivedEvents = new ConcurrentLinkedQueue<>();
@BeforeEach
@@ -55,7 +63,8 @@ public void setUp() {
itemRegistry = getService(ItemRegistry.class);
assertNotNull(itemRegistry);
- itemRegistry.add(new SwitchItem("switch"));
+ switchItem = new SwitchItem("switch");
+ itemRegistry.add(switchItem);
EventSubscriber eventSubscriber = new EventSubscriber() {
@Override
@@ -65,12 +74,18 @@ public void receive(Event event) {
@Override
public Set getSubscribedEventTypes() {
- return Set.of(ItemStateChangedEvent.TYPE);
+ return Set.of(ItemStateChangedEvent.TYPE, ItemStateUpdatedEvent.TYPE, ItemTimeSeriesUpdatedEvent.TYPE);
}
};
registerService(eventSubscriber);
}
+ @AfterEach
+ public void tearDown() {
+ receivedEvents.clear();
+ itemRegistry.remove(switchItem.getName());
+ }
+
@Test
public void testItemUpdaterSetsItemState() {
eventPublisher.post(ItemEventFactory.createStateEvent("switch", OnOffType.ON));
@@ -79,32 +94,91 @@ public void testItemUpdaterSetsItemState() {
}
@Test
- public void testItemUpdaterSendsStateChangedEvent() throws Exception {
+ public void testItemUpdaterSendsStateUpdatedEvent() throws Exception {
eventPublisher.post(ItemEventFactory.createStateEvent("switch", OnOffType.ON));
Item switchItem = itemRegistry.get("switch");
waitForAssert(() -> assertEquals(OnOffType.ON, switchItem.getState()));
+ // wait for the initial events (updated and changed, because it was NULL before)
+ waitForAssert(() -> {
+ assertEquals(2, receivedEvents.size());
+ ItemStateUpdatedEvent updatedEvent = (ItemStateUpdatedEvent) receivedEvents.poll();
+ assertNotNull(updatedEvent);
+ assertEquals(OnOffType.ON, updatedEvent.getItemState());
+ ItemStateChangedEvent changedEvent = (ItemStateChangedEvent) receivedEvents.poll();
+ assertNotNull(changedEvent);
+ assertEquals(UnDefType.NULL, changedEvent.getOldItemState());
+ assertEquals(OnOffType.ON, changedEvent.getItemState());
+ });
+
+ // update with same value
+ eventPublisher.post(ItemEventFactory.createStateEvent("switch", OnOffType.ON));
+
+ // wait for the updated event
+ waitForAssert(() -> {
+ assertEquals(1, receivedEvents.size());
+ ItemStateUpdatedEvent updatedEvent = (ItemStateUpdatedEvent) receivedEvents.poll();
+ assertNotNull(updatedEvent);
+ assertEquals(OnOffType.ON, updatedEvent.getItemState());
+ });
+
+ // ensure no other events send
+ Thread.sleep(1000);
+ assertTrue(receivedEvents.isEmpty());
+ }
+
+ @Test
+ public void testItemUpdaterSendsStateChangedEvent() throws Exception {
+ eventPublisher.post(ItemEventFactory.createStateEvent("switch", OnOffType.ON));
+
+ // wait for the initial events (updated and changed, because it was NULL before)
+ waitForAssert(() -> {
+ assertEquals(2, receivedEvents.size());
+ ItemStateUpdatedEvent updatedEvent = (ItemStateUpdatedEvent) receivedEvents.poll();
+ assertNotNull(updatedEvent);
+ assertEquals(OnOffType.ON, updatedEvent.getItemState());
+ ItemStateChangedEvent changedEvent = (ItemStateChangedEvent) receivedEvents.poll();
+ assertNotNull(changedEvent);
+ assertEquals(UnDefType.NULL, changedEvent.getOldItemState());
+ assertEquals(OnOffType.ON, changedEvent.getItemState());
+ });
+
// change state
eventPublisher.post(ItemEventFactory.createStateEvent("switch", OnOffType.OFF));
- // wait for an event that change the state from OFF to ON
- // there could be one remaining event from the 'ItemUpdater sets item state' test
+ // wait for two events: the updated event and the changed event
waitForAssert(() -> {
- assertFalse(receivedEvents.isEmpty());
+ assertEquals(2, receivedEvents.size());
+ ItemStateUpdatedEvent updatedEvent = (ItemStateUpdatedEvent) receivedEvents.poll();
+ assertNotNull(updatedEvent);
+ assertEquals(OnOffType.OFF, updatedEvent.getItemState());
ItemStateChangedEvent changedEvent = (ItemStateChangedEvent) receivedEvents.poll();
assertNotNull(changedEvent);
assertEquals(OnOffType.ON, changedEvent.getOldItemState());
assertEquals(OnOffType.OFF, changedEvent.getItemState());
});
- // send update for same state
- eventPublisher.post(ItemEventFactory.createStateEvent("switch", OnOffType.OFF));
+ // wait a second and make sure no other events have been sent
+ Thread.sleep(1000);
+ assertTrue(receivedEvents.isEmpty());
+ }
- // wait a few milliseconds
- Thread.sleep(100);
+ @Test
+ public void testItemUpdaterSetsTimeSeries() throws InterruptedException {
+ TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.ADD);
+ timeSeries.add(Instant.now(), OnOffType.ON);
+ eventPublisher.post(ItemEventFactory.createTimeSeriesEvent("switch", timeSeries, null));
+
+ // wait for the event
+ waitForAssert(() -> {
+ assertEquals(1, receivedEvents.size());
+ ItemTimeSeriesUpdatedEvent updatedEvent = (ItemTimeSeriesUpdatedEvent) receivedEvents.poll();
+ assertNotNull(updatedEvent);
+ assertEquals(timeSeries, updatedEvent.getTimeSeries());
+ });
- // make sure no state changed event has been sent
+ Thread.sleep(1000);
assertTrue(receivedEvents.isEmpty());
}
}
diff --git a/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/internal/CommunicationManagerOSGiTest.java b/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/internal/CommunicationManagerOSGiTest.java
index b3893bce00c..8eae74a400e 100644
--- a/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/internal/CommunicationManagerOSGiTest.java
+++ b/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/internal/CommunicationManagerOSGiTest.java
@@ -72,12 +72,13 @@
import org.openhab.core.thing.profiles.ProfileFactory;
import org.openhab.core.thing.profiles.ProfileTypeProvider;
import org.openhab.core.thing.profiles.ProfileTypeUID;
-import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.thing.profiles.TimeSeriesProfile;
import org.openhab.core.thing.profiles.TriggerProfile;
import org.openhab.core.thing.type.ChannelKind;
import org.openhab.core.thing.type.ChannelType;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command;
+import org.openhab.core.types.TimeSeries;
/**
*
@@ -148,7 +149,7 @@ protected void addProvider(Provider provider) {
private @Mock @NonNullByDefault({}) ItemStateConverter itemStateConverterMock;
private @Mock @NonNullByDefault({}) ProfileAdvisor profileAdvisorMock;
private @Mock @NonNullByDefault({}) ProfileFactory profileFactoryMock;
- private @Mock @NonNullByDefault({}) StateProfile stateProfileMock;
+ private @Mock @NonNullByDefault({}) TimeSeriesProfile stateProfileMock;
private @Mock @NonNullByDefault({}) ThingHandler thingHandlerMock;
private @Mock @NonNullByDefault({}) ThingRegistry thingRegistryMock;
private @Mock @NonNullByDefault({}) TriggerProfile triggerProfileMock;
@@ -272,6 +273,32 @@ public void testPostCommandMultiLink() {
verifyNoMoreInteractions(triggerProfileMock);
}
+ @Test
+ public void testTimeSeriesSingleLink() {
+ TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
+
+ manager.sendTimeSeries(STATE_CHANNEL_UID_1, timeSeries);
+
+ waitForAssert(() -> {
+ verify(stateProfileMock).onTimeSeriesFromHandler(eq(timeSeries));
+ });
+ verifyNoMoreInteractions(stateProfileMock);
+ verifyNoMoreInteractions(triggerProfileMock);
+ }
+
+ @Test
+ public void testTimeSeriesMultiLink() {
+ TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
+
+ manager.sendTimeSeries(STATE_CHANNEL_UID_2, timeSeries);
+
+ waitForAssert(() -> {
+ verify(stateProfileMock, times(2)).onTimeSeriesFromHandler(eq(timeSeries));
+ });
+ verifyNoMoreInteractions(stateProfileMock);
+ verifyNoMoreInteractions(triggerProfileMock);
+ }
+
@Test
public void testItemCommandEventSingleLink() {
manager.receive(ItemEventFactory.createCommandEvent(ITEM_NAME_2, OnOffType.ON));