From 60e040c5296c20a0acd2d4b7e54c202f186edf7f Mon Sep 17 00:00:00 2001 From: Yannick Schaus Date: Sat, 15 Feb 2020 18:08:42 +0100 Subject: [PATCH] Metadata-based state & command description providers (#1362) * Metadata-based state & command description providers Implements #1185. These providers will look into item metadata, which can be managed by UIs with the API, to set or override the item's state description (pattern, options, read only...) or command description. Signed-off-by: Yannick Schaus --- .../MetadataCommandDescriptionProvider.java | 84 ++++++++++ ...adataStateDescriptionFragmentProvider.java | 151 ++++++++++++++++++ ...etadataCommandDescriptionProviderTest.java | 120 ++++++++++++++ ...aStateDescriptionFragmentProviderTest.java | 122 ++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataCommandDescriptionProvider.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProvider.java create mode 100644 bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataCommandDescriptionProviderTest.java create mode 100644 bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProviderTest.java diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataCommandDescriptionProvider.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataCommandDescriptionProvider.java new file mode 100644 index 0000000000..10ff49a006 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataCommandDescriptionProvider.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2010-2020 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.internal.items; + +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.internal.types.CommandDescriptionImpl; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.MetadataRegistry; +import org.openhab.core.types.CommandDescription; +import org.openhab.core.types.CommandDescriptionProvider; +import org.openhab.core.types.CommandOption; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link CommandDescription} provider from items' metadata + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(service = CommandDescriptionProvider.class) +public class MetadataCommandDescriptionProvider implements CommandDescriptionProvider { + + private final Logger logger = LoggerFactory.getLogger(MetadataCommandDescriptionProvider.class); + + public static final String COMMANDDESCRIPTION_METADATA_NAMESPACE = "commandDescription"; + + private MetadataRegistry metadataRegistry; + + @Activate + public MetadataCommandDescriptionProvider(final @Reference MetadataRegistry metadataRegistry, + Map properties) { + this.metadataRegistry = metadataRegistry; + } + + @Override + public @Nullable CommandDescription getCommandDescription(@NonNull String itemName, @Nullable Locale locale) { + Metadata metadata = metadataRegistry.get(new MetadataKey(COMMANDDESCRIPTION_METADATA_NAMESPACE, itemName)); + + if (metadata != null) { + try { + CommandDescriptionImpl commandDescription = new CommandDescriptionImpl(); + if (metadata.getConfiguration().containsKey("options")) { + Stream.of(metadata.getConfiguration().get("options").toString().split(",")).forEach(o -> { + if (o.contains("=")) { + commandDescription.addCommandOption( + new CommandOption(o.split("=")[0].trim(), o.split("=")[1].trim())); + } else { + commandDescription.addCommandOption(new CommandOption(o.trim(), null)); + } + }); + + return commandDescription; + } + } catch (Exception e) { + logger.warn("Unable to parse the commandDescription from metadata for item {}, ignoring it", itemName); + return null; + } + } + + return null; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProvider.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProvider.java new file mode 100644 index 0000000000..dcb6ff6b21 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProvider.java @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2010-2020 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.internal.items; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.items.MetadataRegistry; +import org.openhab.core.types.StateDescriptionFragment; +import org.openhab.core.types.StateDescriptionFragmentBuilder; +import org.openhab.core.types.StateDescriptionFragmentProvider; +import org.openhab.core.types.StateOption; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link StateDescriptionFragment} provider from items' metadata + * + * @author Yannick Schaus - initial contribution + * + */ +@NonNullByDefault +@Component(service = StateDescriptionFragmentProvider.class) +public class MetadataStateDescriptionFragmentProvider implements StateDescriptionFragmentProvider { + + private final Logger logger = LoggerFactory.getLogger(MetadataStateDescriptionFragmentProvider.class); + + public static final String STATEDESCRIPTION_METADATA_NAMESPACE = "stateDescription"; + + private final MetadataRegistry metadataRegistry; + + private final Integer rank; + + @Activate + public MetadataStateDescriptionFragmentProvider(final @Reference MetadataRegistry metadataRegistry, + Map properties) { + this.metadataRegistry = metadataRegistry; + + Object serviceRanking = properties.get(Constants.SERVICE_RANKING); + if (serviceRanking instanceof Integer) { + rank = (Integer) serviceRanking; + } else { + rank = 1; // takes precedence over other providers usually ranked 0 + } + } + + @Override + public @Nullable StateDescriptionFragment getStateDescriptionFragment(@NonNull String itemName, + @Nullable Locale locale) { + Metadata metadata = metadataRegistry.get(new MetadataKey(STATEDESCRIPTION_METADATA_NAMESPACE, itemName)); + + if (metadata != null) { + try { + StateDescriptionFragmentBuilder builder = StateDescriptionFragmentBuilder.create(); + if (metadata.getConfiguration().containsKey("pattern")) { + builder.withPattern((String) metadata.getConfiguration().get("pattern")); + } + if (metadata.getConfiguration().containsKey("min")) { + builder.withMinimum(getBigDecimal(metadata.getConfiguration().get("min"))); + } + if (metadata.getConfiguration().containsKey("max")) { + builder.withMaximum(getBigDecimal(metadata.getConfiguration().get("max"))); + } + if (metadata.getConfiguration().containsKey("step")) { + builder.withStep(getBigDecimal(metadata.getConfiguration().get("step"))); + } + if (metadata.getConfiguration().containsKey("readOnly")) { + builder.withReadOnly(getBoolean(metadata.getConfiguration().get("readOnly"))); + } + if (metadata.getConfiguration().containsKey("options")) { + List stateOptions = Stream + .of(metadata.getConfiguration().get("options").toString().split(",")).map(o -> { + return (o.contains("=")) + ? new StateOption(o.split("=")[0].trim(), o.split("=")[1].trim()) + : new StateOption(o.trim(), null); + }).collect(Collectors.toList()); + builder.withOptions(stateOptions); + } + + return builder.build(); + } catch (Exception e) { + logger.warn("Unable to parse the stateDescription from metadata for item {}, ignoring it", itemName); + } + } + + return null; + } + + private BigDecimal getBigDecimal(Object value) { + BigDecimal ret = null; + if (value != null) { + if (value instanceof BigDecimal) { + ret = (BigDecimal) value; + } else if (value instanceof String) { + ret = new BigDecimal((String) value); + } else if (value instanceof BigInteger) { + ret = new BigDecimal((BigInteger) value); + } else if (value instanceof Number) { + ret = new BigDecimal(((Number) value).doubleValue()); + } else { + throw new ClassCastException("Not possible to coerce [" + value + "] from class " + value.getClass() + + " into a BigDecimal."); + } + } + return ret; + } + + private Boolean getBoolean(Object value) { + Boolean ret = null; + if (value != null) { + if (value instanceof Boolean) { + ret = (Boolean) value; + } else if (value instanceof String) { + ret = Boolean.parseBoolean((String) value); + } else { + throw new ClassCastException( + "Not possible to coerce [" + value + "] from class " + value.getClass() + " into a Boolean."); + } + } + return ret; + } + + @Override + public @NonNull Integer getRank() { + return rank; + } +} diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataCommandDescriptionProviderTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataCommandDescriptionProviderTest.java new file mode 100644 index 0000000000..ec205224d1 --- /dev/null +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataCommandDescriptionProviderTest.java @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2010-2020 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.internal.items; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.initMocks; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.openhab.core.items.Item; +import org.openhab.core.items.ManagedMetadataProvider; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.types.CommandDescription; +import org.openhab.core.types.CommandOption; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceEvent; +import org.osgi.framework.ServiceListener; +import org.osgi.framework.ServiceReference; + +/** + * @author Yannick Schaus - Initial contribution + */ +public class MetadataCommandDescriptionProviderTest { + + private static final String ITEM_NAME = "itemName"; + + @SuppressWarnings("rawtypes") + private @Mock ServiceReference managedProviderRef; + private @Mock BundleContext bundleContext; + private @Mock ManagedMetadataProvider managedProvider; + private @Mock Item item; + + private @Mock MetadataRegistryImpl metadataRegistry; + private MetadataCommandDescriptionProvider commandDescriptionProvider; + + private ServiceListener providerTracker; + + @Before + @SuppressWarnings("unchecked") + public void setup() throws Exception { + initMocks(this); + + when(bundleContext.getService(same(managedProviderRef))).thenReturn(managedProvider); + + when(item.getName()).thenReturn(ITEM_NAME); + + metadataRegistry = new MetadataRegistryImpl(); + + metadataRegistry.setManagedProvider(managedProvider); + metadataRegistry.activate(bundleContext); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ServiceListener.class); + verify(bundleContext).addServiceListener(captor.capture(), any()); + providerTracker = captor.getValue(); + providerTracker.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, managedProviderRef)); + + commandDescriptionProvider = new MetadataCommandDescriptionProvider(metadataRegistry, new HashMap<>()); + } + + @Test + public void testEmpty() throws Exception { + CommandDescription commandDescription = commandDescriptionProvider.getCommandDescription(ITEM_NAME, null); + assertNull(commandDescription); + } + + @Test + public void testEmptyConfig() throws Exception { + MetadataKey metadataKey = new MetadataKey("commandDescription", ITEM_NAME); + // Map metadataConfig = new HashMap<>(); + Metadata metadata = new Metadata(metadataKey, "N/A", null); + + metadataRegistry.added(managedProvider, metadata); + CommandDescription commandDescription = commandDescriptionProvider.getCommandDescription(ITEM_NAME, null); + assertNull(commandDescription); + } + + @Test + public void testOptions() throws Exception { + MetadataKey metadataKey = new MetadataKey("commandDescription", ITEM_NAME); + Map metadataConfig = new HashMap<>(); + metadataConfig.put("options", "OPTION1,OPTION2 , 3 =Option 3 "); + Metadata metadata = new Metadata(metadataKey, "N/A", metadataConfig); + + metadataRegistry.added(managedProvider, metadata); + CommandDescription commandDescription = commandDescriptionProvider.getCommandDescription(ITEM_NAME, null); + assertNotNull(commandDescription); + assertNotNull(commandDescription.getCommandOptions()); + assertEquals(3, commandDescription.getCommandOptions().size()); + + Iterator it = commandDescription.getCommandOptions().iterator(); + CommandOption commandOption = it.next(); + assertEquals("OPTION1", commandOption.getCommand()); + assertEquals(null, commandOption.getLabel()); + commandOption = it.next(); + assertEquals("OPTION2", commandOption.getCommand()); + assertEquals(null, commandOption.getLabel()); + commandOption = it.next(); + assertEquals("3", commandOption.getCommand()); + assertEquals("Option 3", commandOption.getLabel()); + } +} diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProviderTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProviderTest.java new file mode 100644 index 0000000000..4dbe6dec67 --- /dev/null +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/internal/items/MetadataStateDescriptionFragmentProviderTest.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2010-2020 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.internal.items; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.initMocks; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.openhab.core.items.Item; +import org.openhab.core.items.ManagedMetadataProvider; +import org.openhab.core.items.Metadata; +import org.openhab.core.items.MetadataKey; +import org.openhab.core.types.StateDescriptionFragment; +import org.openhab.core.types.StateOption; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceEvent; +import org.osgi.framework.ServiceListener; +import org.osgi.framework.ServiceReference; + +/** + * @author Yannick Schaus - Initial contribution + */ +public class MetadataStateDescriptionFragmentProviderTest { + + private static final String ITEM_NAME = "itemName"; + + @SuppressWarnings("rawtypes") + private @Mock ServiceReference managedProviderRef; + private @Mock BundleContext bundleContext; + private @Mock ManagedMetadataProvider managedProvider; + private @Mock Item item; + + private @Mock MetadataRegistryImpl metadataRegistry; + private MetadataStateDescriptionFragmentProvider stateDescriptionFragmentProvider; + + private ServiceListener providerTracker; + + @Before + @SuppressWarnings("unchecked") + public void setup() throws Exception { + initMocks(this); + + when(bundleContext.getService(same(managedProviderRef))).thenReturn(managedProvider); + + when(item.getName()).thenReturn(ITEM_NAME); + + metadataRegistry = new MetadataRegistryImpl(); + + metadataRegistry.setManagedProvider(managedProvider); + metadataRegistry.activate(bundleContext); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ServiceListener.class); + verify(bundleContext).addServiceListener(captor.capture(), any()); + providerTracker = captor.getValue(); + providerTracker.serviceChanged(new ServiceEvent(ServiceEvent.REGISTERED, managedProviderRef)); + + stateDescriptionFragmentProvider = new MetadataStateDescriptionFragmentProvider(metadataRegistry, + new HashMap<>()); + } + + @Test + public void testEmpty() throws Exception { + StateDescriptionFragment stateDescriptionFragment = stateDescriptionFragmentProvider + .getStateDescriptionFragment(ITEM_NAME, null); + assertNull(stateDescriptionFragment); + } + + @SuppressWarnings("null") + @Test + public void testFragment() throws Exception { + MetadataKey metadataKey = new MetadataKey("stateDescription", ITEM_NAME); + Map metadataConfig = new HashMap<>(); + metadataConfig.put("pattern", "%.1f %unit%"); + metadataConfig.put("min", 18.5); + metadataConfig.put("max", "34"); + metadataConfig.put("step", 3); + metadataConfig.put("readOnly", "true"); + metadataConfig.put("options", "OPTION1,OPTION2 , 3 =Option 3 "); + Metadata metadata = new Metadata(metadataKey, "N/A", metadataConfig); + metadataRegistry.added(managedProvider, metadata); + + StateDescriptionFragment stateDescriptionFragment = stateDescriptionFragmentProvider + .getStateDescriptionFragment(ITEM_NAME, null); + assertNotNull(stateDescriptionFragment); + assertEquals("%.1f %unit%", stateDescriptionFragment.getPattern()); + assertEquals(new BigDecimal(18.5), stateDescriptionFragment.getMinimum()); + assertEquals(new BigDecimal(34), stateDescriptionFragment.getMaximum()); + assertEquals(new BigDecimal(3), stateDescriptionFragment.getStep()); + assertEquals(true, stateDescriptionFragment.isReadOnly()); + assertNotNull(stateDescriptionFragment.getOptions()); + Iterator it = stateDescriptionFragment.getOptions().iterator(); + StateOption stateOption = it.next(); + assertEquals("OPTION1", stateOption.getValue()); + assertEquals(null, stateOption.getLabel()); + stateOption = it.next(); + assertEquals("OPTION2", stateOption.getValue()); + assertEquals(null, stateOption.getLabel()); + stateOption = it.next(); + assertEquals("3", stateOption.getValue()); + assertEquals("Option 3", stateOption.getLabel()); + } +}