From 6c4b3c7186eefb9133eb3c08386569086cf20a17 Mon Sep 17 00:00:00 2001 From: Espen Fossen Date: Sun, 30 Mar 2025 08:03:24 +0200 Subject: [PATCH] [emotiva] Tag group handler and bugfixes. (#18437) * Adds tag group subscription handler to reduce load on processor devices. * Fixes matching of input label to Emotiva source command. * Fixes cleaning of source input to work via REST calls. * Fixes updating of custom user labels on binding startup. * Fixes issue with missing event publisher in EmotivaInputStateOptionProvider. Signed-off-by: Espen Fossen --- bundles/org.openhab.binding.emotiva/README.md | 33 ++- .../internal/EmotivaBindingConstants.java | 11 + .../internal/EmotivaConfiguration.java | 3 + ...a => EmotivaInputStateOptionProvider.java} | 48 ++-- .../internal/EmotivaProcessorHandler.java | 224 +++++++++++++++--- .../internal/EmotivaProcessorState.java | 20 +- .../EmotivaSubscriptionTagGroupHandler.java | 112 +++++++++ .../protocol/EmotivaControlCommands.java | 73 +++++- .../protocol/EmotivaControlRequest.java | 38 ++- .../protocol/EmotivaSubscriptionTagGroup.java | 36 +++ .../protocol/EmotivaSubscriptionTags.java | 133 ++++++----- .../main/resources/OH-INF/config/config.xml | 21 ++ .../resources/OH-INF/i18n/emotiva.properties | 6 + .../EmotivaInputStateOptionProviderTest.java | 119 ++++++++++ ...motivaSubscriptionTagGroupHandlerTest.java | 87 +++++++ .../protocol/AbstractEmotivaControlTest.java | 79 ++++++ .../protocol/EmotivaControlCommandsTest.java | 62 +++++ .../protocol/EmotivaControlRequestTest.java | 47 +--- 18 files changed, 953 insertions(+), 199 deletions(-) rename bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/{InputStateOptionProvider.java => EmotivaInputStateOptionProvider.java} (61%) create mode 100644 bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandler.java create mode 100644 bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTagGroup.java create mode 100644 bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProviderTest.java create mode 100644 bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandlerTest.java create mode 100644 bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/AbstractEmotivaControlTest.java create mode 100644 bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommandsTest.java diff --git a/bundles/org.openhab.binding.emotiva/README.md b/bundles/org.openhab.binding.emotiva/README.md index 459b3718a0..e4dd1f6130 100644 --- a/bundles/org.openhab.binding.emotiva/README.md +++ b/bundles/org.openhab.binding.emotiva/README.md @@ -18,17 +18,28 @@ The binding automatically discovers devices on your network. The Emotiva Processor thing requires the `ipAddress` it can connect to. There are more parameters which all have defaults set. -| Parameter | Values | Default | -|-----------------------|---------------------------------------------------------------|---------| -| ipAddress | IP address of the processor | - | -| controlPort | port number, e.g. 7002 | 7002 | -| notifyPort | port number, e.g. 7003 | 7003 | -| infoPort | port number, e.g. 7004 | 7004 | -| setupPortTCP | port number, e.g. 7100 | 7100 | -| menuNotifyPort | port number, e.g. 7005 | 7005 | -| protocolVersion | Emotiva Network Protocol version, e.g. 3.0 | 2.0 | -| keepAlive | Time between notification update from device, in milliseconds | 7500 | -| retryConnectInMinutes | Time between connection retry, in minutes | 2 | +| Parameter | Values | Default | +|------------------------|---------------------------------------------------------------|---------| +| ipAddress | IP address of the processor | - | +| controlPort | port number, e.g. 7002 | 7002 | +| notifyPort | port number, e.g. 7003 | 7003 | +| infoPort | port number, e.g. 7004 | 7004 | +| setupPortTCP | port number, e.g. 7100 | 7100 | +| menuNotifyPort | port number, e.g. 7005 | 7005 | +| activateFrontBar | Activates Front Bar channels | false | +| activateOSDMenu | Activates OSD menu channels | false | +| activateZone2 | Activates Zone 2 channels | false | +| protocolVersion | Emotiva Network Protocol version, e.g. 3.0 | 2.0 | +| keepAlive | Time between notification update from device, in milliseconds | 7500 | +| retryConnectInMinutes | Time between connection retry, in minutes | 2 | + +### Dynamic channels and extended functionality + +Emotiva processors have limited processing power, so if the binding subscribes to all channels simultaneously the +device might grind to a halt after a while, requiring a manual reboot of the device. The binding is designed to +dynamically enable and disabled channels based on the selected source. Furthermore, the configuration values +**activateFrontBar**, **activateOSDMenu** and **activateZone2** controls extra functionality this is default off to +reduce the overall load on the device. ## Channels diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java index d032cd91cc..5abb24477b 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaBindingConstants.java @@ -47,6 +47,12 @@ public class EmotivaBindingConstants { /** Connection retry interval in minutes */ static final int DEFAULT_RETRY_INTERVAL_MINUTES = 2; + /** Refresh source labels interval in minutes */ + static final int DEFAULT_REFRESH_SOURCE_LABEL_JOB_IN_MINUTES = 60; + + /** Refresh source labels unsubscribe delay, giving enough time to get answer from device */ + static final int DEFAULT_REFRESH_SOURCE_UNSUBSCRIBE_DELAY_IN_SECONDS = 60; + /** * Default Emotiva device keep alive in milliseconds. {@link org.openhab.binding.emotiva.internal.dto.ControlDTO} */ @@ -94,6 +100,11 @@ public class EmotivaBindingConstants { static final String MENU_PANEL_HIGHLIGHTED = "true"; static final String EMOTIVA_SOURCE_COMMAND_PREFIX = "source_"; + /** Emotiva Configuration Constants **/ + public static final String CONFIG_ACTIVATE_FRONT_BAR = "activateFrontBar"; + public static final String CONFIG_ACTIVATE_OSD_MENU = "activateOSDMenu"; + public static final String CONFIG_ACTIVATE_ZONE2 = "activateZone2"; + /** Emotiva Protocol V1 channels **/ public static final String CHANNEL_STANDBY = "general#standby"; public static final String CHANNEL_MAIN_ZONE_POWER = "main-zone#power"; diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java index ad0c251196..fbde6c60cd 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaConfiguration.java @@ -30,6 +30,9 @@ public class EmotivaConfiguration { public int infoPort = 7004; public int setupPortTCP = 7100; public int menuNotifyPort = 7005; + public boolean activateFrontBar = false; + public boolean activateOSDMenu = false; + public boolean activateZone2 = false; public String protocolVersion = DEFAULT_EMOTIVA_PROTOCOL_VERSION; public int keepAlive = DEFAULT_KEEP_ALIVE_IN_MILLISECONDS; public int retryConnectInMinutes = DEFAULT_RETRY_INTERVAL_MINUTES; diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/InputStateOptionProvider.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProvider.java similarity index 61% rename from bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/InputStateOptionProvider.java rename to bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProvider.java index 78fccbf2f7..5b67591ae3 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/InputStateOptionProvider.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProvider.java @@ -28,13 +28,20 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands; import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags; +import org.openhab.core.events.EventPublisher; import org.openhab.core.thing.Channel; import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.thing.type.DynamicStateDescriptionProvider; import org.openhab.core.types.StateDescription; import org.openhab.core.types.StateOption; +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; @@ -44,13 +51,24 @@ import org.slf4j.LoggerFactory; * @author Kai Kreuzer - Initial contribution * @author Espen Fossen - Adapted to Emotiva binding */ +@Component(service = { DynamicStateDescriptionProvider.class, EmotivaInputStateOptionProvider.class }) @NonNullByDefault -public class InputStateOptionProvider extends BaseDynamicStateDescriptionProvider implements ThingHandlerService { +public class EmotivaInputStateOptionProvider extends BaseDynamicStateDescriptionProvider + implements ThingHandlerService { - private final Logger logger = LoggerFactory.getLogger(InputStateOptionProvider.class); + private final Logger logger = LoggerFactory.getLogger(EmotivaInputStateOptionProvider.class); private @Nullable EmotivaProcessorHandler handler; + @Activate + public EmotivaInputStateOptionProvider(final @Reference EventPublisher eventPublisher, + final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, + final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.eventPublisher = eventPublisher; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + } + @Override public void setThingHandler(ThingHandler handler) { this.handler = (EmotivaProcessorHandler) handler; @@ -73,9 +91,9 @@ public class InputStateOptionProvider extends BaseDynamicStateDescriptionProvide EmotivaProcessorHandler localHandler = handler; if (localHandler != null) { if (channel.getUID().getId().equals(CHANNEL_SOURCE)) { - setStateOptionsForSource(channel, options, localHandler.getSourcesMainZone()); + options.addAll(gatherStateOptionsForSource(channel, localHandler.getSourcesMainZone())); } else if (channel.getUID().getId().equals(CHANNEL_ZONE2_SOURCE)) { - setStateOptionsForSource(channel, options, localHandler.getSourcesZone2()); + options.addAll(gatherStateOptionsForSource(channel, localHandler.getSourcesZone2())); } else if (channel.getUID().getId().equals(CHANNEL_MODE)) { EnumMap modes = localHandler.getModes(); Collection modeKeys = modes.keySet(); @@ -83,24 +101,26 @@ public class InputStateOptionProvider extends BaseDynamicStateDescriptionProvide options.add(new StateOption(modeKey.name(), modes.get(modeKey))); } logger.trace("Updating OH channel '{}' with state options '{}'", CHANNEL_MODE, options); - setStateOptions(channel.getUID(), options); } + setStateOptions(channel.getUID(), options); } - return super.getStateDescription(channel, original, locale); } - private void setStateOptionsForSource(Channel channel, List options, - EnumMap sources) { - Collection sourceKeys = sources.keySet(); - for (EmotivaControlCommands sourceKey : sourceKeys) { - if (sourceKey.name().startsWith(EMOTIVA_SOURCE_COMMAND_PREFIX)) { - options.add(new StateOption(sourceKey.name(), sources.get(sourceKey))); + List gatherStateOptionsForSource(Channel channel, + EnumMap sourcesWithUserLabels) { + List options = new ArrayList<>(); + Collection sourceCommands = sourcesWithUserLabels.keySet(); + for (EmotivaControlCommands command : sourceCommands) { + // If command name starts with prefix "source_", add the user provided label, else use default label from + // command. + if (command.name().startsWith(EMOTIVA_SOURCE_COMMAND_PREFIX)) { + options.add(new StateOption(command.name(), sourcesWithUserLabels.get(command))); } else { - options.add(new StateOption(sourceKey.name(), sourceKey.getLabel())); + options.add(new StateOption(command.name(), command.getLabel())); } } logger.trace("Updating OH channel '{}' with state options '{}'", channel.getUID().getId(), options); - setStateOptions(channel.getUID(), options); + return options; } } diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java index 70930810d5..ecda7a1cda 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorHandler.java @@ -25,8 +25,8 @@ import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlComman import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.STRING; import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.NOT_VALID; import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.protocolFromConfig; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup.SOURCES; import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.keepAlive; -import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.noSubscriptionToChannel; import java.io.IOException; import java.io.InterruptedIOException; @@ -39,11 +39,15 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.EnumMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; import javax.measure.quantity.Frequency; import javax.xml.bind.JAXBException; @@ -67,6 +71,7 @@ import org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType; import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands; import org.openhab.binding.emotiva.internal.protocol.EmotivaControlRequest; import org.openhab.binding.emotiva.internal.protocol.EmotivaDataType; +import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup; import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags; import org.openhab.binding.emotiva.internal.protocol.EmotivaUdpResponse; import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils; @@ -112,10 +117,12 @@ public class EmotivaProcessorHandler extends BaseThingHandler { private final List zone2Subscriptions = EmotivaSubscriptionTags.channels("zone2"); private final EmotivaProcessorState state = new EmotivaProcessorState(); + private final EmotivaSubscriptionTagGroupHandler subscriptionHandler; private final EmotivaTranslationProvider i18nProvider; private @Nullable ScheduledFuture pollingJob; private @Nullable ScheduledFuture connectRetryJob; + private @Nullable ScheduledFuture sourceLabelRefreshJob; private @Nullable EmotivaUdpSendingService sendingService; private @Nullable EmotivaUdpReceivingService notifyListener; private @Nullable EmotivaUdpReceivingService menuNotifyListener; @@ -135,6 +142,7 @@ public class EmotivaProcessorHandler extends BaseThingHandler { super(thing); this.i18nProvider = i18nProvider; this.config = getConfigAs(EmotivaConfiguration.class); + this.subscriptionHandler = new EmotivaSubscriptionTagGroupHandler(config, state); this.retryConnectInMinutes = config.retryConnectInMinutes; } @@ -180,9 +188,7 @@ public class EmotivaProcessorHandler extends BaseThingHandler { for (int attempt = 1; attempt <= DEFAULT_CONNECTION_RETRIES && !udpSenderActive; attempt++) { try { logger.debug("Connection attempt '{}'", attempt); - sendConnector.sendSubscription(generalSubscription, config); - sendConnector.sendSubscription(mainZoneSubscriptions, config); - sendConnector.sendSubscription(zone2Subscriptions, config); + sendConnector.sendSubscription(subscriptionHandler.init(), config); } catch (IOException e) { // network or socket failure, also wait 2 sec and try again } @@ -195,6 +201,8 @@ public class EmotivaProcessorHandler extends BaseThingHandler { if (udpSenderActive) { updateStatus(ThingStatus.ONLINE); state.updateLastSeen(ZonedDateTime.now(ZoneId.systemDefault()).toInstant()); + setInitialSourceLabels(); + starthSourceLabelsRefresJob(); startPollingKeepAlive(); final var menuListenerConnector = new EmotivaUdpReceivingService(localConfig.menuNotifyPort, @@ -220,7 +228,10 @@ public class EmotivaProcessorHandler extends BaseThingHandler { private void scheduleConnectRetry(long waitMinutes) { logger.debug("Scheduling connection retry in '{}' minutes", waitMinutes); - connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES); + final ScheduledFuture localScheduleConnectRetryJob = connectRetryJob; + if (localScheduleConnectRetryJob == null || localScheduleConnectRetryJob.isCancelled()) { + connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES); + } } /** @@ -245,6 +256,82 @@ public class EmotivaProcessorHandler extends BaseThingHandler { } } + /** + * Starts a polling job for refreshing source labels + */ + private void starthSourceLabelsRefresJob() { + final ScheduledFuture localSourceLabelRefreshJob = sourceLabelRefreshJob; + if (localSourceLabelRefreshJob == null || localSourceLabelRefreshJob.isCancelled()) { + sourceLabelRefreshJob = scheduler.scheduleWithFixedDelay(this::refreshSourceLabels, 0, + DEFAULT_REFRESH_SOURCE_LABEL_JOB_IN_MINUTES, TimeUnit.MINUTES); + logger.debug("Started scheduled job to update source labels now and every {}min", + DEFAULT_REFRESH_SOURCE_LABEL_JOB_IN_MINUTES); + } + } + + private void refreshSourceLabels() { + EmotivaUdpSendingService localSendingService = sendingService; + if (localSendingService != null) { + try { + localSendingService + .sendSubscription(EmotivaSubscriptionTags.getBySubscriptionTagGroups(Set.of(SOURCES)), config); + scheduler.schedule(this::unsubscribeSourceLabels, DEFAULT_REFRESH_SOURCE_UNSUBSCRIBE_DELAY_IN_SECONDS, + TimeUnit.SECONDS); + } catch (InterruptedIOException e) { + logger.debug("Interrupted during sending of EmotivaSubscription message to device '{}'", thing.getUID(), + e); + } catch (IOException e) { + logger.warn("Failed to send EmotivaSubscription message to device '{}'", thing.getUID(), e); + } + } + } + + private void unsubscribeSourceLabels() { + EmotivaUdpSendingService localSendingService = sendingService; + if (localSendingService != null) { + try { + localSendingService + .sendUnsubscribe(EmotivaSubscriptionTags.getBySubscriptionTagGroups(Set.of(SOURCES))); + } catch (InterruptedIOException e) { + logger.debug("Interrupted during sending of EmotivaUnsubscribe message to device '{}'", thing.getUID(), + e); + } catch (IOException e) { + logger.warn("Failed to send EmotivaUnsubscribe message to device '{}'", thing.getUID(), e); + } + } + } + + private void subscribeTagGroups(Set groups) { + EmotivaUdpSendingService localSendingService = sendingService; + if (localSendingService != null) { + try { + localSendingService.sendSubscription(EmotivaSubscriptionTags.getBySubscriptionTagGroups(groups), + config); + state.updateSubscribedTagGroups(groups); + } catch (InterruptedIOException e) { + logger.debug("Interrupted during sending of EmotivaSubscription message to device '{}'", thing.getUID(), + e); + } catch (IOException e) { + logger.warn("Failed to send EmotivaSubscription message to device '{}'", thing.getUID(), e); + } + } + } + + private void unsubscribeTagGroups(Set groups) { + EmotivaUdpSendingService localSendingService = sendingService; + if (localSendingService != null) { + try { + localSendingService.sendUnsubscribe(EmotivaSubscriptionTags.getBySubscriptionTagGroups(groups)); + state.updateUnsubscribedTagGroups(groups); + } catch (InterruptedIOException e) { + logger.debug("Interrupted during sending of EmotivaUnsubscribe message to device '{}'", thing.getUID(), + e); + } catch (IOException e) { + logger.warn("Failed to send EmotivaUnsubscribe message to device '{}'", thing.getUID(), e); + } + } + } + private void checkKeepAliveTimestamp() { Instant lastKeepAliveTimestamp = state.getLastSeen(); @@ -355,17 +442,6 @@ public class EmotivaProcessorHandler extends BaseThingHandler { } else if (object instanceof EmotivaSubscriptionResponse answerDto) { logger.trace("Processing received '{}' with '{}'", EmotivaSubscriptionResponse.class.getSimpleName(), emotivaUdpResponse.answer()); - // Populates static input sources, except input - EnumMap sourceMainZone = EmotivaControlCommands - .getCommandsFromType(EmotivaCommandType.SOURCE_MAIN_ZONE); - sourceMainZone.remove(EmotivaControlCommands.input); - state.setSourcesMainZone(sourceMainZone); - - EnumMap sourcesZone2 = EmotivaControlCommands - .getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2); - sourcesZone2.remove(EmotivaControlCommands.input); - state.setSourcesZone2(sourcesZone2); - if (answerDto.getProperties() == null) { for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) { handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck()); @@ -379,6 +455,21 @@ public class EmotivaProcessorHandler extends BaseThingHandler { } } + /** + * Sets initial source labels based on command list. + */ + private void setInitialSourceLabels() { + EnumMap sourceMainZone = EmotivaControlCommands + .getByCommandType(EmotivaCommandType.SOURCE_MAIN_ZONE); + sourceMainZone.remove(EmotivaControlCommands.input); + state.setSourcesMainZone(sourceMainZone); + + EnumMap sourcesZone2 = EmotivaControlCommands + .getByCommandType(EmotivaCommandType.SOURCE_ZONE2); + sourcesZone2.remove(EmotivaControlCommands.input); + state.setSourcesZone2(sourcesZone2); + } + private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) { String highlightValue = ""; @@ -469,16 +560,16 @@ public class EmotivaProcessorHandler extends BaseThingHandler { } } - private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) { + private void handleChannelUpdate(String emotivaSubscriptionName, String rawValue, String visible, String status) { logger.trace("Subscription property '{}' with raw value '{}' received, start processing", - emotivaSubscriptionName, value); + emotivaSubscriptionName, rawValue); if (status.equals(NOT_VALID.name())) { logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName); return; } - if ("None".equals(value)) { + if ("None".equals(rawValue)) { logger.debug( "Subscription property '{}' has no value, no update needed, usually means a speaker is not enabled", emotivaSubscriptionName); @@ -489,7 +580,7 @@ public class EmotivaProcessorHandler extends BaseThingHandler { state.updateLastSeen(ZonedDateTime.now(ZoneId.systemDefault()).toInstant()); logger.trace( "Subscription property '{}' with value '{}' mapped to last-seen for device '{}', value updated", - keepAlive.name(), value.trim(), thing.getUID()); + keepAlive.name(), rawValue, thing.getUID()); return; } @@ -501,13 +592,6 @@ public class EmotivaProcessorHandler extends BaseThingHandler { return; } - if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) { - logger.debug( - "Subscription property '{}' is not mapped to a OH channel, no update needed, only used for logging", - emotivaSubscriptionName); - return; - } - try { EmotivaSubscriptionTags subscriptionTag; try { @@ -519,19 +603,20 @@ public class EmotivaProcessorHandler extends BaseThingHandler { } if (subscriptionTag.getChannel().isEmpty()) { - logger.debug("Subscription property '{}' does not have a corresponding OH channel, skipping", - emotivaSubscriptionName); + logger.debug( + "Subscription property '{}' with value '{}' does not have a corresponding OH channel, skipping", + emotivaSubscriptionName, rawValue); return; } - String trimmedValue = value.trim(); + String trimmedValue = rawValue.trim(); logger.trace("Subscription property '{}' with value '{}' mapped to OH channel '{}'", subscriptionTag, trimmedValue, subscriptionTag.getChannel()); - // Add/Update user assigned name for inputs + // Add/Update user assigned name for sources if (subscriptionTag.getChannel().startsWith(CHANNEL_INPUT1.substring(0, CHANNEL_INPUT1.indexOf("-") + 1)) && "true".equals(visible)) { - state.updateSourcesMainZone(EmotivaControlCommands.matchToInput(subscriptionTag.name()), trimmedValue); + state.updateSourcesMainZone(EmotivaControlCommands.matchFromSourceInput(subscriptionTag), trimmedValue); logger.trace("Adding/Updating '{}' to OH channel '{}' state options, all options are now {}", trimmedValue, CHANNEL_SOURCE, state.getSourcesMainZone()); } @@ -621,6 +706,21 @@ public class EmotivaProcessorHandler extends BaseThingHandler { logger.debug("Preparing to update OH channel '{}' with value:type '{}:{}'", channelName, value, StringType.class.getSimpleName()); updateChannelState(channelName, StringType.valueOf(value)); + if (channelName.equals(CHANNEL_SOURCE)) { + EmotivaControlCommands matchedSource = matchCommandFromSourceAndLabels(value); + if (matchedSource.equals(none)) { + logger.error( + "Error trying to get source command from OhCommand '{}:{}', not able to update subscriptions", + channelName, value); + } else { + BiConsumer, Set> tagGroups = ( + subscribeSet, unsubscribeSet) -> { + updateTagGroupChangesForSource(subscribeSet, unsubscribeSet, value, matchedSource); + }; + subscriptionHandler.tagGroupsFromSource(matchedSource, tagGroups); + logger.debug("Currently subscribed tag groups: '{}'", state.getSubscriptionsTagGroups()); + } + } } case UNKNOWN -> // Do nothing, types not connect to channels logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value); @@ -630,6 +730,43 @@ public class EmotivaProcessorHandler extends BaseThingHandler { } } + /** + * Updates tags subscriptions by the binding based on a source command and tag groups. The subscribe and unsubscribe + * sets are matched against the currently subscribed tag groups to reduce number of needed subscription calls and + * avoid recursive updates. + * + * @param subscribeSet Tag groups to subscribe to + * @param unsubscribeSet Tag groups to unsubscribe from + * @param value Input value from channel er device + * @param matchedSource Source command matched from input value + */ + private void updateTagGroupChangesForSource(Set subscribeSet, + Set unsubscribeSet, String value, EmotivaControlCommands matchedSource) { + Set currentSubscriptionTagGroups = state.getSubscriptionsTagGroups(); + var unsubscribe = unsubscribeSet.stream().filter(currentSubscriptionTagGroups::contains) + .collect(Collectors.toCollection(HashSet::new)); + if (!unsubscribe.isEmpty()) { + unsubscribeTagGroups(unsubscribeSet); + } + var subscribe = subscribeSet.stream().filter(group -> !currentSubscriptionTagGroups.contains(group)) + .collect(Collectors.toCollection(HashSet::new)); + if (!subscribe.isEmpty()) { + subscribeTagGroups(subscribe); + } + logger.debug("Input '{}' matched to source '{}' subscribing to '{}' and unsubscribing from '{}'", value, + matchedSource.name(), subscribe, unsubscribe); + } + + public EmotivaControlCommands matchCommandFromSourceAndLabels(String value) { + Map map = state.getCommandMap(MAP_SOURCES_MAIN_ZONE); + EmotivaControlCommands command = EmotivaControlCommands.matchFromSourceInput(value, map); + if (command.equals(none)) { + logger.debug("Could not match OH command {} with value '{}' to values in map '{}', no matching command", + CHANNEL_SOURCE, value, map); + } + return command; + } + private void updateChannelState(String channelID, State channelState) { state.updateChannel(channelID, channelState); logger.trace("Updating OH channel '{}' with value '{}'", channelID, channelState); @@ -670,7 +807,26 @@ public class EmotivaProcessorHandler extends BaseThingHandler { .or(() -> Optional.of(emotivaRequest.createDTO(ohCommand, UnDefType.UNDEF))); localSendingService.send(dto.get()); - if (emotivaRequest.getName().equals(EmotivaControlCommands.volume.name()) + if (emotivaRequest.getDefaultCommand().equals(EmotivaControlCommands.input) + && ohCommand instanceof StringType sourceString) { + try { + EmotivaControlCommands source = EmotivaControlCommands.valueOf(sourceString.toString()); + BiConsumer, Set> tagGroups = ( + subscribeSet, unsubscribeSet) -> { + logger.debug( + "OhCommand '{}:{}' is a source command with subscribeSet '{}' and unsubscribeSet '{}'", + channelUID.getId(), ohCommand, subscribeSet, unsubscribeSet); + updateTagGroupChangesForSource(subscribeSet, unsubscribeSet, sourceString.toString(), + source); + }; + subscriptionHandler.tagGroupsFromSource(source, tagGroups); + logger.debug("Currently subscribed tag groups: '{}'", state.getSubscriptionsTagGroups()); + } catch (IllegalArgumentException e) { + logger.error( + "Error trying to get source command from OhCommand '{}:{}', not able to update subscriptions", + channelUID.getId(), ohCommand); + } + } else if (emotivaRequest.getName().equals(EmotivaControlCommands.volume.name()) || emotivaRequest.getName().equals(EmotivaControlCommands.set_volume.name())) { logger.debug("OhCommand '{}:{}' is of type main zone volume", channelUID.getId(), ohCommand); if (ohCommand instanceof PercentType value) { @@ -776,7 +932,7 @@ public class EmotivaProcessorHandler extends BaseThingHandler { @Override public Collection> getServices() { - return Set.of(InputStateOptionProvider.class); + return Set.of(EmotivaInputStateOptionProvider.class); } public EnumMap getSourcesMainZone() { diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorState.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorState.java index 164a7e9581..3de92b9c00 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorState.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaProcessorState.java @@ -24,11 +24,14 @@ import java.time.Instant; import java.util.Collections; import java.util.EnumMap; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands; +import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup; import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags; import org.openhab.core.types.State; @@ -44,6 +47,7 @@ public class EmotivaProcessorState { private EnumMap sourcesMainZone; private EnumMap sourcesZone2; + private final Set subscriptions = new HashSet<>(); private final EnumMap modes; private Instant lastSeen = Instant.EPOCH; @@ -137,8 +141,8 @@ public class EmotivaProcessorState { channelStateMap.put(channel, state); } - public void updateSourcesMainZone(EmotivaControlCommands command, String value) { - sourcesMainZone.put(command, value); + public void updateSourcesMainZone(EmotivaControlCommands command, String label) { + sourcesMainZone.put(command, label.trim()); } public void updateModes(EmotivaSubscriptionTags tag, String value) { @@ -156,4 +160,16 @@ public class EmotivaProcessorState { public Instant getLastSeen() { return lastSeen; } + + public Set getSubscriptionsTagGroups() { + return subscriptions; + } + + public void updateSubscribedTagGroups(Set toAddSubscriptions) { + subscriptions.addAll(toAddSubscriptions); + } + + public void updateUnsubscribedTagGroups(Set toRemoveSubscriptions) { + subscriptions.removeAll(toRemoveSubscriptions); + } } diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandler.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandler.java new file mode 100644 index 0000000000..e1ffe09207 --- /dev/null +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandler.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.emotiva.internal; + +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup.UI_DEVICE; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands; +import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup; +import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles subscription tags against av given Emotiva device. Devices have limited processing power, so the device do + * not expect an integration to subscribe to all possible tags at the same time. Tags are divided into groups that + * correspond with source/input the device is using or configuration values set in the binding. + * + * @author Espen Fossen - Initial contribution + */ +@NonNullByDefault +public class EmotivaSubscriptionTagGroupHandler { + + private final Logger logger = LoggerFactory.getLogger(EmotivaSubscriptionTagGroupHandler.class); + + private final EmotivaConfiguration config; + private final EmotivaProcessorState state; + private final List basicSubscriptions = List.of(EmotivaSubscriptionTagGroup.GENERAL, + EmotivaSubscriptionTagGroup.AUDIO_ADJUSTMENT); + + public EmotivaSubscriptionTagGroupHandler(EmotivaConfiguration config, EmotivaProcessorState state) { + this.config = config; + this.state = state; + } + + public List init() { + Set unsubscribe = new HashSet<>(); + Set subscribe = new HashSet<>(basicSubscriptions); + + if (config.activateFrontBar) { + subscribe.add(UI_DEVICE); + } else { + unsubscribe.add(UI_DEVICE); + } + if (config.activateOSDMenu) { + subscribe.add(EmotivaSubscriptionTagGroup.UI_MENU); + } else { + unsubscribe.add(EmotivaSubscriptionTagGroup.UI_MENU); + } + if (config.activateZone2) { + subscribe.add(EmotivaSubscriptionTagGroup.ZONE2_GENERAL); + } else { + unsubscribe.add(EmotivaSubscriptionTagGroup.ZONE2_GENERAL); + } + + state.updateUnsubscribedTagGroups(unsubscribe); + state.updateSubscribedTagGroups(subscribe); + Set tagGroups = state.getSubscriptionsTagGroups(); + List tags = EmotivaSubscriptionTags.getBySubscriptionTagGroups(tagGroups); + logger.trace("Subscribing to tag groups '{}' and tags '{}' on binding startup.", tagGroups, tags); + return tags; + } + + /** + * Updates a BiConsumer with subscribe and unsubscribe tag groups based on provided source. + * + * @param source Provided source. + * @param tagGroups Consumer for subscribe and unsubscribe tag groups. + */ + public void tagGroupsFromSource(EmotivaControlCommands source, + BiConsumer, Set> tagGroups) { + Set unsubscribeSet = new HashSet<>(); + Set subscribeSet = new HashSet<>(); + + switch (source) { + case analog1, analog2, analog3, analog4, analog5, analog71, ARC, coax1, coax2, coax3, coax4, front_in, + optical1, optical2, optical3, optical4, usb_stream -> { + subscribeSet.add(EmotivaSubscriptionTagGroup.AUDIO_INFO); + unsubscribeSet.add(EmotivaSubscriptionTagGroup.TUNER); + unsubscribeSet.add(EmotivaSubscriptionTagGroup.VIDEO_INFO); + } + case hdmi1, hdmi2, hdmi3, hdmi4, hdmi5, hdmi6, hdmi7, hdmi8, source_1, source_2, source_3, source_4, + source_5, source_6, source_7, source_8 -> { + subscribeSet.add(EmotivaSubscriptionTagGroup.AUDIO_INFO); + subscribeSet.add(EmotivaSubscriptionTagGroup.VIDEO_INFO); + unsubscribeSet.add(EmotivaSubscriptionTagGroup.TUNER); + } + case tuner -> { + subscribeSet.add(EmotivaSubscriptionTagGroup.TUNER); + subscribeSet.add(EmotivaSubscriptionTagGroup.AUDIO_INFO); + unsubscribeSet.add(EmotivaSubscriptionTagGroup.VIDEO_INFO); + } + default -> subscribeSet.add(EmotivaSubscriptionTagGroup.VIDEO_INFO); + } + tagGroups.accept(subscribeSet, unsubscribeSet); + } +} diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java index f6d38041c7..f0b2d172b0 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommands.java @@ -33,6 +33,7 @@ import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.STRI import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.UNKNOWN; import java.util.EnumMap; +import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -202,18 +203,62 @@ public enum EmotivaControlCommands { this.dataType = dataType; } - public static EmotivaControlCommands matchToInput(String inputName) { - for (EmotivaControlCommands value : values()) { - if (inputName.toLowerCase().equals(value.name())) { - return value; + /** + * Matches given tag to Emotiva command using the following rules: + * + * 1. Check if tag name matches any known {@link EmotivaControlCommands} command. + * 2. Check if tag name has a corresponding {@link EmotivaControlCommands} source command (input_ -> source_). + * + * @param tag Input tag + * @return Command matched from tag or else {@link EmotivaControlCommands#none} + */ + public static EmotivaControlCommands matchFromSourceInput(EmotivaSubscriptionTags tag) { + for (EmotivaControlCommands command : values()) { + if (tag.name().equalsIgnoreCase(command.name())) { + return command; } } - if (inputName.startsWith("input_")) { - return valueOf(inputName.replace("input_", "source_")); + if (tag.name().toLowerCase().startsWith("input_")) { + return valueOf(tag.name().toLowerCase().replace("input_", "source_")); } return none; } + /** + * Matches a string input to a {@link EmotivaControlCommands} source command, uses the rules listed below for + * matching. User customised labels might have equal content for multiple sources, so it is up to the user to keep + * each unique, if not the first in the set is returned. + * + * 1. First match between of input label and label from map. + * 2. Check if input matches name of any command in map. + * 3. Check if input matches name of any known {@link EmotivaControlCommands}. + * + * @param input Label or command input + * @param sourceCommandToLabelMap Source to label map + * @return Command matched from input or else {@link EmotivaControlCommands#none} + */ + public static EmotivaControlCommands matchFromSourceInput(String input, + Map sourceCommandToLabelMap) { + // Cleanup input, Emotiva labels might have spaces and REST API might add line breaks + String inputTrimmed = input.trim().replaceAll("(\r\n|\n)", ""); + + for (EmotivaControlCommands command : sourceCommandToLabelMap.keySet()) { + String label = sourceCommandToLabelMap.get(command); + if (label != null && label.equals(inputTrimmed)) { + return command; + } else if (command.name().equalsIgnoreCase(inputTrimmed)) { + return command; + } + } + for (EmotivaControlCommands command : values()) { + // Check if input label matches label of the current command list + if (inputTrimmed.equalsIgnoreCase(command.name())) { + return command; + } + } + return EmotivaControlCommands.none; + } + public String getLabel() { return label; } @@ -226,13 +271,17 @@ public enum EmotivaControlCommands { return dataType; } - public static EnumMap getCommandsFromType(EmotivaCommandType filter) { + /** + * Provides map of Emotiva command to label for given {@link EmotivaCommandType}. + * + * @param filter Filter provided for reducing the map to a given type + * @return Map + */ + public static EnumMap getByCommandType(EmotivaCommandType filter) { EnumMap commands = new EnumMap<>(EmotivaControlCommands.class); - for (EmotivaControlCommands value : values()) { - if (value.getCommandType().equals(filter)) { - var sb = new StringBuilder(value.name()); - sb.setCharAt(0, Character.toUpperCase(value.name().charAt(0))); - commands.put(value, sb.toString()); + for (EmotivaControlCommands command : values()) { + if (command.getCommandType().equals(filter)) { + commands.put(command, command.label); } } return commands; diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java index 53a6457216..3f51f8ab2e 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequest.java @@ -16,10 +16,9 @@ import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*; import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.clamp; import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumePercentageToDecibel; import static org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType.*; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.none; import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.FREQUENCY_HERTZ; -import java.util.Map; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.emotiva.internal.EmotivaProcessorState; import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO; @@ -168,16 +167,16 @@ public class EmotivaControlRequest { case NONE -> { switch (channel) { case CHANNEL_TUNER_BAND -> { - return matchToCommandMap(ohCommand, MAP_TUNER_BANDS); + return getDTOFromCommandInMap(ohCommand, MAP_TUNER_BANDS); } case CHANNEL_TUNER_CHANNEL_SELECT -> { - return matchToCommandMap(ohCommand, MAP_TUNER_CHANNELS); + return getDTOFromCommandInMap(ohCommand, MAP_TUNER_CHANNELS); } case CHANNEL_SOURCE -> { - return matchToCommandMap(ohCommand, MAP_SOURCES_MAIN_ZONE); + return getDTOFromCommandInMap(ohCommand, MAP_SOURCES_MAIN_ZONE); } case CHANNEL_ZONE2_SOURCE -> { - return matchToCommandMap(ohCommand, MAP_SOURCES_ZONE_2); + return getDTOFromCommandInMap(ohCommand, MAP_SOURCES_ZONE_2); } default -> { logger.debug( @@ -325,21 +324,19 @@ public class EmotivaControlRequest { } } - private EmotivaControlDTO matchToCommandMap(Command ohCommand, String mapName) { - if (ohCommand instanceof StringType value) { - Map commandMap = state.getCommandMap(mapName); - for (EmotivaControlCommands command : commandMap.keySet()) { - String map = commandMap.get(command); - if (map != null && map.equals(value.toString())) { - return EmotivaControlDTO.create(EmotivaControlCommands.matchToInput(command.toString())); - } else if (command.name().equalsIgnoreCase(value.toString())) { - return EmotivaControlDTO.create(command); - } + public EmotivaControlDTO getDTOFromCommandInMap(Command ohCommand, String mapName) { + if (ohCommand instanceof StringType ohCommandValue) { + EmotivaControlCommands command = EmotivaControlCommands.matchFromSourceInput(ohCommandValue.toString(), + state.getCommandMap(mapName)); + if (command.equals(none)) { + logger.debug("Could not match OH command {}:{} with value '{}' to '{}' map, no matching command", + channel, ohCommand.getClass().getSimpleName(), ohCommand.toFullString(), mapName); } + return EmotivaControlDTO.create(command); + } else { + logger.debug("Could not match OH command {}:{} with value '{}' to '{}' map , not a OH StringType", channel, + ohCommand.getClass().getSimpleName(), ohCommand.toFullString(), mapName); } - logger.debug( - "OH command {}:{} mapped to Emotiva Command type {} could not be matched to a Emotiva control command, not a OH String type", - channel, ohCommand.getClass().getSimpleName(), NONE); return EmotivaControlDTO.create(EmotivaControlCommands.none); } @@ -514,7 +511,6 @@ public class EmotivaControlRequest { return "EmotivaControlRequest{" + "name='" + name + '\'' + ", dataType=" + dataType + ", channel='" + channel + '\'' + ", defaultCommand=" + defaultCommand + ", setCommand=" + setCommand + ", onCommand=" + onCommand + ", offCommand=" + offCommand + ", upCommand=" + upCommand + ", downCommand=" + downCommand - + ", maxValue=" + maxValue + ", minValue=" + minValue + ", state=" + state + ", protocolVersion=" - + protocolVersion + '}'; + + ", maxValue=" + maxValue + ", minValue=" + minValue + ", protocolVersion=" + protocolVersion + '}'; } } diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTagGroup.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTagGroup.java new file mode 100644 index 0000000000..6221a0496a --- /dev/null +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTagGroup.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.emotiva.internal.protocol; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Emotiva subscription tag group. Each Emotiva subscription tag is assigned to group in + * {@link EmotivaSubscriptionTags} for the possibility of handling via + * {@link org.openhab.binding.emotiva.internal.EmotivaSubscriptionTagGroupHandler}. + * + * @author Espen Fossen - Initial contribution + */ +@NonNullByDefault +public enum EmotivaSubscriptionTagGroup { + AUDIO_ADJUSTMENT, + AUDIO_INFO, + GENERAL, + SOURCES, + TUNER, + UI_DEVICE, + UI_MENU, + VIDEO_INFO, + NONE, + ZONE2_GENERAL +} diff --git a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java index 89da168200..35b96ee026 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java +++ b/bundles/org.openhab.binding.emotiva/src/main/java/org/openhab/binding/emotiva/internal/protocol/EmotivaSubscriptionTags.java @@ -14,9 +14,11 @@ package org.openhab.binding.emotiva.internal.protocol; import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*; import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.*; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup.*; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.slf4j.Logger; @@ -31,70 +33,70 @@ import org.slf4j.LoggerFactory; public enum EmotivaSubscriptionTags { /* Protocol V1 notify tags */ - power("power", ON_OFF, CHANNEL_MAIN_ZONE_POWER), - source("source", STRING, CHANNEL_SOURCE), - dim("dim", DIMENSIONLESS_PERCENT, CHANNEL_DIM), - mode("mode", STRING, CHANNEL_MODE), - speaker_preset("speaker-preset", STRING, CHANNEL_SPEAKER_PRESET), - center("center", DIMENSIONLESS_DECIBEL, CHANNEL_CENTER), - subwoofer("subwoofer", DIMENSIONLESS_DECIBEL, CHANNEL_SUBWOOFER), - surround("surround", DIMENSIONLESS_DECIBEL, CHANNEL_SURROUND), - back("back", DIMENSIONLESS_DECIBEL, CHANNEL_BACK), - volume("volume", DIMENSIONLESS_DECIBEL, CHANNEL_MAIN_VOLUME), - loudness("loudness", ON_OFF, CHANNEL_LOUDNESS), - treble("treble", DIMENSIONLESS_DECIBEL, CHANNEL_TREBLE), - bass("bass", DIMENSIONLESS_DECIBEL, CHANNEL_BASS), - zone2_power("zone2-power", ON_OFF, CHANNEL_ZONE2_POWER), - zone2_volume("zone2-volume", DIMENSIONLESS_DECIBEL, CHANNEL_ZONE2_VOLUME), - zone2_input("zone2-input", STRING, CHANNEL_ZONE2_SOURCE), - tuner_band("tuner-band", STRING, CHANNEL_TUNER_BAND), - tuner_channel("tuner-channel", FREQUENCY_HERTZ, CHANNEL_TUNER_CHANNEL), - tuner_signal("tuner-signal", STRING, CHANNEL_TUNER_SIGNAL), - tuner_program("tuner-program", STRING, CHANNEL_TUNER_PROGRAM), - tuner_RDS("tuner-RDS", STRING, CHANNEL_TUNER_RDS), - audio_input("audio-input", STRING, CHANNEL_AUDIO_INPUT), - audio_bitstream("audio-bitstream", STRING, CHANNEL_AUDIO_BITSTREAM), - audio_bits("audio-bits", STRING, CHANNEL_AUDIO_BITS), - video_input("video-input", STRING, CHANNEL_VIDEO_INPUT), - video_format("video-format", STRING, CHANNEL_VIDEO_FORMAT), - video_space("video-space", STRING, CHANNEL_VIDEO_SPACE), - input_1("input-1", STRING, CHANNEL_INPUT1), - input_2("input-2", STRING, CHANNEL_INPUT2), - input_3("input-3", STRING, CHANNEL_INPUT3), - input_4("input-4", STRING, CHANNEL_INPUT4), - input_5("input-5", STRING, CHANNEL_INPUT5), - input_6("input-6", STRING, CHANNEL_INPUT6), - input_7("input-7", STRING, CHANNEL_INPUT7), - input_8("input-8", STRING, CHANNEL_INPUT8), + power("power", ON_OFF, CHANNEL_MAIN_ZONE_POWER, GENERAL), + source("source", STRING, CHANNEL_SOURCE, GENERAL), + dim("dim", DIMENSIONLESS_PERCENT, CHANNEL_DIM, UI_DEVICE), + mode("mode", STRING, CHANNEL_MODE, GENERAL), + speaker_preset("speaker-preset", STRING, CHANNEL_SPEAKER_PRESET, AUDIO_ADJUSTMENT), + center("center", DIMENSIONLESS_DECIBEL, CHANNEL_CENTER, AUDIO_ADJUSTMENT), + subwoofer("subwoofer", DIMENSIONLESS_DECIBEL, CHANNEL_SUBWOOFER, AUDIO_ADJUSTMENT), + surround("surround", DIMENSIONLESS_DECIBEL, CHANNEL_SURROUND, AUDIO_ADJUSTMENT), + back("back", DIMENSIONLESS_DECIBEL, CHANNEL_BACK, AUDIO_ADJUSTMENT), + volume("volume", DIMENSIONLESS_DECIBEL, CHANNEL_MAIN_VOLUME, GENERAL), + loudness("loudness", ON_OFF, CHANNEL_LOUDNESS, AUDIO_ADJUSTMENT), + treble("treble", DIMENSIONLESS_DECIBEL, CHANNEL_TREBLE, AUDIO_ADJUSTMENT), + bass("bass", DIMENSIONLESS_DECIBEL, CHANNEL_BASS, AUDIO_ADJUSTMENT), + zone2_power("zone2-power", ON_OFF, CHANNEL_ZONE2_POWER, ZONE2_GENERAL), + zone2_volume("zone2-volume", DIMENSIONLESS_DECIBEL, CHANNEL_ZONE2_VOLUME, ZONE2_GENERAL), + zone2_input("zone2-input", STRING, CHANNEL_ZONE2_SOURCE, ZONE2_GENERAL), + tuner_band("tuner-band", STRING, CHANNEL_TUNER_BAND, TUNER), + tuner_channel("tuner-channel", FREQUENCY_HERTZ, CHANNEL_TUNER_CHANNEL, TUNER), + tuner_signal("tuner-signal", STRING, CHANNEL_TUNER_SIGNAL, TUNER), + tuner_program("tuner-program", STRING, CHANNEL_TUNER_PROGRAM, TUNER), + tuner_RDS("tuner-RDS", STRING, CHANNEL_TUNER_RDS, TUNER), + audio_input("audio-input", STRING, CHANNEL_AUDIO_INPUT, AUDIO_INFO), + audio_bitstream("audio-bitstream", STRING, CHANNEL_AUDIO_BITSTREAM, AUDIO_INFO), + audio_bits("audio-bits", STRING, CHANNEL_AUDIO_BITS, AUDIO_INFO), + video_input("video-input", STRING, CHANNEL_VIDEO_INPUT, VIDEO_INFO), + video_format("video-format", STRING, CHANNEL_VIDEO_FORMAT, VIDEO_INFO), + video_space("video-space", STRING, CHANNEL_VIDEO_SPACE, VIDEO_INFO), + input_1("input-1", STRING, CHANNEL_INPUT1, SOURCES), + input_2("input-2", STRING, CHANNEL_INPUT2, SOURCES), + input_3("input-3", STRING, CHANNEL_INPUT3, SOURCES), + input_4("input-4", STRING, CHANNEL_INPUT4, SOURCES), + input_5("input-5", STRING, CHANNEL_INPUT5, SOURCES), + input_6("input-6", STRING, CHANNEL_INPUT6, SOURCES), + input_7("input-7", STRING, CHANNEL_INPUT7, SOURCES), + input_8("input-8", STRING, CHANNEL_INPUT8, SOURCES), /* Protocol V2 notify tags */ - selected_mode("selected-mode", STRING, CHANNEL_SELECTED_MODE), - selected_movie_music("selected-movie-music", STRING, CHANNEL_SELECTED_MOVIE_MUSIC), - mode_ref_stereo("mode-ref-stereo", STRING, CHANNEL_MODE_REF_STEREO), - mode_stereo("mode-stereo", STRING, CHANNEL_MODE_STEREO), - mode_music("mode-music", STRING, CHANNEL_MODE_MUSIC), - mode_movie("mode-movie", STRING, CHANNEL_MODE_MOVIE), - mode_direct("mode-direct", STRING, CHANNEL_MODE_DIRECT), - mode_dolby("mode-dolby", STRING, CHANNEL_MODE_DOLBY), - mode_dts("mode-dts", STRING, CHANNEL_MODE_DTS), - mode_all_stereo("mode-all-stereo", STRING, CHANNEL_MODE_ALL_STEREO), - mode_auto("mode-auto", STRING, CHANNEL_MODE_AUTO), - mode_surround("mode-surround", STRING, CHANNEL_MODE_SURROUND), - menu("menu", ON_OFF, CHANNEL_MENU), - menu_update("menu-update", STRING, CHANNEL_MENU_DISPLAY_PREFIX), + selected_mode("selected-mode", STRING, CHANNEL_SELECTED_MODE, GENERAL), + selected_movie_music("selected-movie-music", STRING, CHANNEL_SELECTED_MOVIE_MUSIC, GENERAL), + mode_ref_stereo("mode-ref-stereo", STRING, CHANNEL_MODE_REF_STEREO, SOURCES), + mode_stereo("mode-stereo", STRING, CHANNEL_MODE_STEREO, SOURCES), + mode_music("mode-music", STRING, CHANNEL_MODE_MUSIC, SOURCES), + mode_movie("mode-movie", STRING, CHANNEL_MODE_MOVIE, SOURCES), + mode_direct("mode-direct", STRING, CHANNEL_MODE_DIRECT, SOURCES), + mode_dolby("mode-dolby", STRING, CHANNEL_MODE_DOLBY, SOURCES), + mode_dts("mode-dts", STRING, CHANNEL_MODE_DTS, SOURCES), + mode_all_stereo("mode-all-stereo", STRING, CHANNEL_MODE_ALL_STEREO, SOURCES), + mode_auto("mode-auto", STRING, CHANNEL_MODE_AUTO, SOURCES), + mode_surround("mode-surround", STRING, CHANNEL_MODE_SURROUND, SOURCES), + menu("menu", ON_OFF, CHANNEL_MENU, UI_MENU), + menu_update("menu-update", STRING, CHANNEL_MENU_DISPLAY_PREFIX, UI_MENU), /* Protocol V3 notify tags */ - keepAlive("keepAlive", NUMBER_TIME, ""), - goodBye("goodBye", GOODBYE, ""), - bar_update("bar-update", STRING, CHANNEL_BAR), - width("width", DIMENSIONLESS_DECIBEL, CHANNEL_WIDTH), - height("height", DIMENSIONLESS_DECIBEL, CHANNEL_HEIGHT), + keepAlive("keepAlive", NUMBER_TIME, "", GENERAL), + goodBye("goodBye", GOODBYE, "", GENERAL), + bar_update("bar-update", STRING, CHANNEL_BAR, UI_DEVICE), + width("width", DIMENSIONLESS_DECIBEL, CHANNEL_WIDTH, AUDIO_ADJUSTMENT), + height("height", DIMENSIONLESS_DECIBEL, CHANNEL_HEIGHT, AUDIO_ADJUSTMENT), /* Notify tag not in the documentation */ - source_tuner("source-tuner", ON_OFF, ""), + source_tuner("source-tuner", ON_OFF, "", SOURCES), /* No match tag */ - unknown("unknown", UNKNOWN, ""); + unknown("unknown", UNKNOWN, "", EmotivaSubscriptionTagGroup.NONE); private final Logger logger = LoggerFactory.getLogger(EmotivaSubscriptionTags.class); @@ -104,11 +106,14 @@ public enum EmotivaSubscriptionTags { private final String name; private final EmotivaDataType dataType; private final String channel; + private final EmotivaSubscriptionTagGroup subscriptionTagGroup; - EmotivaSubscriptionTags(String name, EmotivaDataType dataType, String channel) { + EmotivaSubscriptionTags(String name, EmotivaDataType dataType, String channel, + EmotivaSubscriptionTagGroup subscriptionTagGroup) { this.name = name; this.dataType = dataType; this.channel = channel; + this.subscriptionTagGroup = subscriptionTagGroup; } public static boolean hasChannel(String name) { @@ -157,8 +162,16 @@ public enum EmotivaSubscriptionTags { return tags.toArray(new EmotivaSubscriptionTags[0]); } - public static List noSubscriptionToChannel() { - return List.of(goodBye); + public static List getBySubscriptionTagGroups(Set groups) { + List tags = new ArrayList<>(); + for (EmotivaSubscriptionTagGroup group : groups) { + for (EmotivaSubscriptionTags value : values()) { + if (value.subscriptionTagGroup.equals(group)) { + tags.add(value); + } + } + } + return tags; } public String getName() { @@ -167,7 +180,7 @@ public enum EmotivaSubscriptionTags { public String getEmotivaName() { String retVal = name.replaceAll("-", "_"); - logger.debug("Converting OH channel '{}' to Emotiva command '{}'", name, retVal); + logger.trace("Converting OH channel '{}' to Emotiva command '{}'", name, retVal); return retVal; } diff --git a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml index c0477ae3df..e3a89b5428 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/config/config.xml @@ -45,6 +45,27 @@ 7100 true + + feature + + Subscribe to notification related to front bar + false + false + + + feature + + Subscribe to notification related to OSD menu + false + false + + + feature + + Subscribe to notification related to zone 2 + false + false + protocol-revision diff --git a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties index 6d7389e9e8..acec911b34 100644 --- a/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties +++ b/bundles/org.openhab.binding.emotiva/src/main/resources/OH-INF/i18n/emotiva.properties @@ -22,6 +22,12 @@ thing-type.config.emotiva.config.infoPort.label = Info Port thing-type.config.emotiva.config.infoPort.description = UDP port thing-type.config.emotiva.config.setupPortTCP.label = Setup TCP Port thing-type.config.emotiva.config.setupPortTCP.description = TCP port for remote setup +thing-type.config.emotiva.config.activateFrontBar.label = Front Bar Notifications +thing-type.config.emotiva.config.activateFrontBar.description = Activate notification for front bar channel +thing-type.config.emotiva.config.activateOSDMenu.label = OSD Menu Notifications +thing-type.config.emotiva.config.activateOSDMenu.description = Activate notification for menu channel +thing-type.config.emotiva.config.activateZone2.label = Zone 2 Notifications +thing-type.config.emotiva.config.activateZone2.description = Activate notification for Zone 2 channels thing-type.config.emotiva.config.menuNotifyPort.label = Menu Notify Port thing-type.config.emotiva.config.menuNotifyPort.description = UDP port to receive menu notifications from the device thing-type.config.emotiva.config.protocolVersion.label = Emotiva Protocol Version diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProviderTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProviderTest.java new file mode 100644 index 0000000000..0a5bcc174f --- /dev/null +++ b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaInputStateOptionProviderTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.emotiva.internal; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.BINDING_ID; +import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.THING_PROCESSOR; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.coax1; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.hdmi1; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.source_1; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.source_2; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.source; + +import java.util.EnumMap; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelGroupUID; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.StateOption; + +/** + * Unit tests for {@link EmotivaInputStateOptionProvider}. + * + * @author Espen Fossen - Initial contribution + */ +@NonNullByDefault +class EmotivaInputStateOptionProviderTest { + + @Test + void initialMainZoneSourcesToStateOptionsWithCustomLabels() { + EnumMap mainZoneSourcesMap = new EnumMap<>(EmotivaControlCommands.class); + mainZoneSourcesMap.put(source_1, "Input 1"); + mainZoneSourcesMap.put(source_2, "SHIELD"); + mainZoneSourcesMap.put(hdmi1, "HDMI 1"); + mainZoneSourcesMap.put(coax1, "Coax 1"); + EmotivaProcessorState state = new EmotivaProcessorState(); + state.setSourcesMainZone(mainZoneSourcesMap); + + EmotivaInputStateOptionProvider provider = new EmotivaInputStateOptionProvider(mock(EventPublisher.class), + mock(ItemChannelLinkRegistry.class), mock(ChannelTypeI18nLocalizationService.class)); + + ThingUID thingUID = new ThingUID(THING_PROCESSOR, "XMC-2"); + ChannelUID channelUID = new ChannelUID(new ChannelGroupUID(thingUID, "main-zone"), source.name()); + Channel channel = ChannelBuilder.create(channelUID).withType(new ChannelTypeUID(BINDING_ID, source.name())) + .build(); + + List stateOptions = provider.gatherStateOptionsForSource(channel, mainZoneSourcesMap); + assertThat(stateOptions, not(nullValue())); + assertThat(stateOptions.size(), is(4)); + assertThat(stateOptions.get(0).getValue(), is(source_1.name())); + assertThat(stateOptions.get(0).getLabel(), is("Input 1")); + assertThat(stateOptions.get(1).getValue(), is(source_2.name())); + assertThat(stateOptions.get(1).getLabel(), is("SHIELD")); // Overridden by user + assertThat(stateOptions.get(2).getValue(), is(hdmi1.name())); + assertThat(stateOptions.get(2).getLabel(), is("HDMI 1")); + assertThat(stateOptions.get(3).getValue(), is(coax1.name())); + assertThat(stateOptions.get(3).getLabel(), is("Coax 1")); + } + + @Test + void updatedMaineZoneSourcesToStateOptionsWithCustomLabels() { + EnumMap mainZoneSourcesMap = new EnumMap<>(EmotivaControlCommands.class); + mainZoneSourcesMap.put(source_1, "Input 1"); + mainZoneSourcesMap.put(source_2, "SHIELD"); + mainZoneSourcesMap.put(hdmi1, "HDMI 1"); + mainZoneSourcesMap.put(coax1, "Coax 1"); + EmotivaProcessorState state = new EmotivaProcessorState(); + state.setSourcesMainZone(mainZoneSourcesMap); + + EmotivaInputStateOptionProvider provider = new EmotivaInputStateOptionProvider(mock(EventPublisher.class), + mock(ItemChannelLinkRegistry.class), mock(ChannelTypeI18nLocalizationService.class)); + + ThingUID thingUID = new ThingUID(THING_PROCESSOR, "XMC-2"); + ChannelUID channelUID = new ChannelUID(new ChannelGroupUID(thingUID, "main-zone"), source.name()); + Channel channel = ChannelBuilder.create(channelUID).withType(new ChannelTypeUID(BINDING_ID, source.name())) + .build(); + + String sourceOneLabel = "Should be added to options"; + state.updateSourcesMainZone(EmotivaControlCommands.source_1, sourceOneLabel); + state.updateSourcesMainZone(EmotivaControlCommands.hdmi1, "Should not be added to options"); + List stateOptions = provider.gatherStateOptionsForSource(channel, mainZoneSourcesMap); + + assertThat(stateOptions, not(nullValue())); + assertThat(stateOptions.size(), is(4)); + assertThat(stateOptions.get(0), not(nullValue())); + assertThat(stateOptions.get(0).getValue(), is(source_1.name())); + assertThat(stateOptions.get(0).getLabel(), is(sourceOneLabel)); + assertThat(stateOptions.get(1).getValue(), is(source_2.name())); + assertThat(stateOptions.get(1).getLabel(), is("SHIELD")); // Overridden by user + assertThat(stateOptions.get(2).getValue(), is(hdmi1.name())); + assertThat(stateOptions.get(2).getLabel(), is("HDMI 1")); + assertThat(stateOptions.get(3).getValue(), is(coax1.name())); + assertThat(stateOptions.get(3).getLabel(), is("Coax 1")); + } +} diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandlerTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandlerTest.java new file mode 100644 index 0000000000..7dabf10822 --- /dev/null +++ b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/EmotivaSubscriptionTagGroupHandlerTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.emotiva.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup.AUDIO_INFO; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup.TUNER; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup.VIDEO_INFO; + +import java.util.Set; +import java.util.function.BiConsumer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands; +import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTagGroup; + +/** + * Unit tests for {@link EmotivaSubscriptionTagGroupHandler}. + * + * @author Espen Fossen - Initial contribution + */ +@NonNullByDefault +class EmotivaSubscriptionTagGroupHandlerTest { + + @Test + void sourceTuner() { + EmotivaConfiguration config = new EmotivaConfiguration(); + EmotivaProcessorState state = new EmotivaProcessorState(); + var subscriptionHandler = new EmotivaSubscriptionTagGroupHandler(config, state); + + BiConsumer, Set> tagGroups = (subscribeSet, + unsubscribeSet) -> { + assertThat(subscribeSet, hasItems(TUNER)); + assertThat(subscribeSet, not(hasItems(VIDEO_INFO))); + assertThat(unsubscribeSet, hasItems(VIDEO_INFO)); + assertThat(unsubscribeSet, not(hasItems(TUNER))); + }; + subscriptionHandler.tagGroupsFromSource(EmotivaControlCommands.tuner, tagGroups); + } + + @Test + void sourceAudioOnly() { + EmotivaConfiguration config = new EmotivaConfiguration(); + EmotivaProcessorState state = new EmotivaProcessorState(); + var subscriptionHandler = new EmotivaSubscriptionTagGroupHandler(config, state); + + BiConsumer, Set> tagGroups = (subscribeSet, + unsubscribeSet) -> { + assertThat(subscribeSet, hasItems(AUDIO_INFO)); + assertThat(subscribeSet, not(hasItems(TUNER))); + assertThat(unsubscribeSet, hasItems(TUNER)); + assertThat(unsubscribeSet, not(hasItems(AUDIO_INFO))); + }; + subscriptionHandler.tagGroupsFromSource(EmotivaControlCommands.analog1, tagGroups); + } + + @Test + void sourceAudioAndVideo() { + EmotivaConfiguration config = new EmotivaConfiguration(); + EmotivaProcessorState state = new EmotivaProcessorState(); + var subscriptionHandler = new EmotivaSubscriptionTagGroupHandler(config, state); + + BiConsumer, Set> tagGroups = (subscribeSet, + unsubscribeSet) -> { + assertThat(subscribeSet, hasItems(AUDIO_INFO)); + assertThat(subscribeSet, hasItems(VIDEO_INFO)); + assertThat(subscribeSet, not(hasItems(TUNER))); + assertThat(unsubscribeSet, hasItems(TUNER)); + assertThat(unsubscribeSet, not(hasItems(AUDIO_INFO))); + assertThat(unsubscribeSet, not(hasItems(VIDEO_INFO))); + }; + subscriptionHandler.tagGroupsFromSource(EmotivaControlCommands.hdmi1, tagGroups); + } +} diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/AbstractEmotivaControlTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/AbstractEmotivaControlTest.java new file mode 100644 index 0000000000..f9bd749963 --- /dev/null +++ b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/AbstractEmotivaControlTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.emotiva.internal.protocol; + +import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_FREQUENCY; +import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_TREBLE; +import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.CHANNEL_TUNER_CHANNEL; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.*; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.band_am; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.band_fm; + +import java.util.EnumMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.BeforeAll; +import org.openhab.binding.emotiva.internal.EmotivaProcessorState; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.Units; + +/** + * Common setup for tests related to the {@link EmotivaControlCommands}. + * + * @author Espen Fossen - Initial contribution + */ +@NonNullByDefault +public class AbstractEmotivaControlTest { + + protected static final EnumMap MAP_SOURCES_MAIN_ZONE = new EnumMap<>( + EmotivaControlCommands.class); + protected static final EnumMap MAP_SOURCES_ZONE_2 = new EnumMap<>( + EmotivaControlCommands.class); + protected static final EnumMap CHANNEL_MAP = new EnumMap<>( + EmotivaControlCommands.class); + protected static final EnumMap RADIO_BAND_MAP = new EnumMap<>( + EmotivaControlCommands.class); + protected static final EmotivaProcessorState state = new EmotivaProcessorState(); + + @BeforeAll + static void beforeAll() { + MAP_SOURCES_MAIN_ZONE.put(source_1, "Input 1"); + MAP_SOURCES_MAIN_ZONE.put(source_2, "SHIELD"); + MAP_SOURCES_MAIN_ZONE.put(hdmi1, "HDMI 1"); + MAP_SOURCES_MAIN_ZONE.put(coax1, "Coax 1"); + state.setSourcesMainZone(MAP_SOURCES_MAIN_ZONE); + + MAP_SOURCES_ZONE_2.put(source_1, "HDMI 1"); + MAP_SOURCES_ZONE_2.put(source_2, "SHIELD"); + MAP_SOURCES_ZONE_2.put(hdmi1, "HDMI 1"); + MAP_SOURCES_ZONE_2.put(zone2_coax1, "Coax 1"); + MAP_SOURCES_ZONE_2.put(zone2_ARC, "Audio Return Channel"); + MAP_SOURCES_ZONE_2.put(zone2_follow_main, "Follow Main"); + state.setSourcesZone2(MAP_SOURCES_ZONE_2); + + CHANNEL_MAP.put(channel_1, "Channel 1"); + CHANNEL_MAP.put(channel_2, "Channel 2"); + CHANNEL_MAP.put(channel_3, "My Radio Channel"); + state.setChannels(CHANNEL_MAP); + + RADIO_BAND_MAP.put(band_am, "AM"); + RADIO_BAND_MAP.put(band_fm, "FM"); + state.setTunerBands(RADIO_BAND_MAP); + + state.updateChannel(CHANNEL_TREBLE, new DecimalType(-3)); + state.updateChannel(CHANNEL_TUNER_CHANNEL, new StringType("FM 87.50MHz")); + state.updateChannel(CHANNEL_FREQUENCY, QuantityType.valueOf(107.90, Units.HERTZ)); + } +} diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommandsTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommandsTest.java new file mode 100644 index 0000000000..ea07d4994e --- /dev/null +++ b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlCommandsTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.emotiva.internal.protocol; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.analog1; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.hdmi1; +import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.source_2; + +import java.util.Map; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Unit tests for {@link EmotivaControlCommands}. + * + * @author Espen Fossen - Initial contribution + */ +@NonNullByDefault +class EmotivaControlCommandsTest extends AbstractEmotivaControlTest { + + private static Stream mapInputToSourceCommand() { + return Stream.of(Arguments.of("", "none", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of(" ", "none", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of(" ", "none", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of("HDMI1", "hdmi1", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of("HDMI1\n", "hdmi1", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of("HDMI1\r", "hdmi1", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of("\n\rHDMI1\r\n", "hdmi1", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of("Hdmi1", "hdmi1", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of(" Hdmi1 ", "hdmi1", Map.ofEntries(Map.entry(hdmi1, hdmi1.getLabel()))), + Arguments.of("source_2", "source_2", Map.ofEntries(Map.entry(source_2, "SHIELD"))), + Arguments.of("SHIELD", "source_2", Map.ofEntries(Map.entry(source_2, "SHIELD"))), + Arguments.of("Analog 1", "analog1", Map.ofEntries(Map.entry(analog1, "Analog 1"))), + Arguments.of("Analog1", "analog1", Map.ofEntries(Map.entry(analog1, "Analog1")))); + } + + @ParameterizedTest + @MethodSource("mapInputToSourceCommand") + void mapInputToSourceCommand2(String input, String expected, + Map mainZoneSourceOverrides) { + mainZoneSourceOverrides.forEach(state::updateSourcesMainZone); + EmotivaControlCommands sourceCommand = EmotivaControlCommands.matchFromSourceInput(input, + mainZoneSourceOverrides); + assertThat(sourceCommand.name(), is(expected)); + } +} diff --git a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java index d50cd386b9..a7d72e3bc3 100644 --- a/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java +++ b/bundles/org.openhab.binding.emotiva/src/test/java/org/openhab/binding/emotiva/internal/protocol/EmotivaControlRequestTest.java @@ -19,17 +19,14 @@ import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlComman import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.*; import static org.openhab.core.types.RefreshType.REFRESH; -import java.util.EnumMap; import java.util.Optional; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.openhab.binding.emotiva.internal.EmotivaCommandHelper; -import org.openhab.binding.emotiva.internal.EmotivaProcessorState; import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; @@ -48,7 +45,7 @@ import org.openhab.core.types.UnDefType; * @author Espen Fossen - Initial contribution */ @NonNullByDefault -class EmotivaControlRequestTest { +class EmotivaControlRequestTest extends AbstractEmotivaControlTest { private static Stream channelToDTOs() { return Stream.of(Arguments.of(CHANNEL_STANDBY, OnOffType.ON, standby, PROTOCOL_V2, "0"), @@ -132,7 +129,7 @@ class EmotivaControlRequestTest { Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("HDMI1"), hdmi1, PROTOCOL_V2, "0"), Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("SHIELD"), source_2, PROTOCOL_V2, "0"), Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("hdmi1"), hdmi1, PROTOCOL_V2, "0"), - Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("coax1"), none, PROTOCOL_V2, "0"), + Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("coax1"), coax1, PROTOCOL_V2, "0"), Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("zone2_coax1"), zone2_coax1, PROTOCOL_V2, "0"), Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("zone2_ARC"), zone2_ARC, PROTOCOL_V2, "0"), Arguments.of(CHANNEL_ZONE2_SOURCE, new StringType("NOT_REAL"), none, PROTOCOL_V2, "0"), @@ -207,46 +204,6 @@ class EmotivaControlRequestTest { "-2.0")); } - private static final EnumMap MAP_SOURCES_MAIN_ZONE = new EnumMap<>( - EmotivaControlCommands.class); - private static final EnumMap MAP_SOURCES_ZONE_2 = new EnumMap<>( - EmotivaControlCommands.class); - private static final EnumMap CHANNEL_MAP = new EnumMap<>( - EmotivaControlCommands.class); - private static final EnumMap RADIO_BAND_MAP = new EnumMap<>( - EmotivaControlCommands.class); - private static final EmotivaProcessorState state = new EmotivaProcessorState(); - - @BeforeAll - static void beforeAll() { - MAP_SOURCES_MAIN_ZONE.put(source_1, "HDMI 1"); - MAP_SOURCES_MAIN_ZONE.put(source_2, "SHIELD"); - MAP_SOURCES_MAIN_ZONE.put(hdmi1, "HDMI1"); - MAP_SOURCES_MAIN_ZONE.put(coax1, "Coax 1"); - state.setSourcesMainZone(MAP_SOURCES_MAIN_ZONE); - - MAP_SOURCES_ZONE_2.put(source_1, "HDMI 1"); - MAP_SOURCES_ZONE_2.put(source_2, "SHIELD"); - MAP_SOURCES_ZONE_2.put(hdmi1, "HDMI1"); - MAP_SOURCES_ZONE_2.put(zone2_coax1, "Coax 1"); - MAP_SOURCES_ZONE_2.put(zone2_ARC, "Audio Return Channel"); - MAP_SOURCES_ZONE_2.put(zone2_follow_main, "Follow Main"); - state.setSourcesZone2(MAP_SOURCES_ZONE_2); - - CHANNEL_MAP.put(channel_1, "Channel 1"); - CHANNEL_MAP.put(channel_2, "Channel 2"); - CHANNEL_MAP.put(channel_3, "My Radio Channel"); - state.setChannels(CHANNEL_MAP); - - RADIO_BAND_MAP.put(band_am, "AM"); - RADIO_BAND_MAP.put(band_fm, "FM"); - state.setTunerBands(RADIO_BAND_MAP); - - state.updateChannel(CHANNEL_TREBLE, new DecimalType(-3)); - state.updateChannel(CHANNEL_TUNER_CHANNEL, new StringType("FM 87.50MHz")); - state.updateChannel(CHANNEL_FREQUENCY, QuantityType.valueOf(107.90, Units.HERTZ)); - } - @ParameterizedTest @MethodSource("channelToDTOs") void createDTO(String channel, Command ohValue, EmotivaControlCommands controlCommand,