2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.emotiva.internal;
15 import static java.lang.String.format;
16 import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
17 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.channelToControlRequest;
18 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.getMenuPanelColumnLabel;
19 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.getMenuPanelRowLabel;
20 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.updateProgress;
21 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumeDecibelToPercentage;
22 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumePercentageToDecibel;
23 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.band_am;
24 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.band_fm;
25 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.channel_1;
26 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.none;
27 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.power_on;
28 import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.STRING;
29 import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.NOT_VALID;
30 import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.protocolFromConfig;
31 import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.noSubscriptionToChannel;
32 import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_band;
33 import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.tuner_channel;
35 import java.io.IOException;
36 import java.io.InterruptedIOException;
37 import java.net.InetAddress;
38 import java.net.UnknownHostException;
39 import java.time.Duration;
40 import java.time.Instant;
41 import java.time.temporal.ChronoUnit;
42 import java.util.Collection;
43 import java.util.Collections;
44 import java.util.EnumMap;
45 import java.util.HashMap;
46 import java.util.List;
49 import java.util.concurrent.ConcurrentHashMap;
50 import java.util.concurrent.ScheduledFuture;
51 import java.util.concurrent.TimeUnit;
53 import javax.measure.quantity.Frequency;
54 import javax.xml.bind.JAXBException;
56 import org.eclipse.jdt.annotation.NonNullByDefault;
57 import org.eclipse.jdt.annotation.Nullable;
58 import org.openhab.binding.emotiva.internal.dto.AbstractNotificationDTO;
59 import org.openhab.binding.emotiva.internal.dto.EmotivaAckDTO;
60 import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyDTO;
61 import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyWrapper;
62 import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
63 import org.openhab.binding.emotiva.internal.dto.EmotivaMenuNotifyDTO;
64 import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyDTO;
65 import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyWrapper;
66 import org.openhab.binding.emotiva.internal.dto.EmotivaPropertyDTO;
67 import org.openhab.binding.emotiva.internal.dto.EmotivaSubscriptionResponse;
68 import org.openhab.binding.emotiva.internal.dto.EmotivaUpdateResponse;
69 import org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType;
70 import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
71 import org.openhab.binding.emotiva.internal.protocol.EmotivaControlRequest;
72 import org.openhab.binding.emotiva.internal.protocol.EmotivaDataType;
73 import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
74 import org.openhab.binding.emotiva.internal.protocol.EmotivaUdpResponse;
75 import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils;
76 import org.openhab.core.common.NamedThreadFactory;
77 import org.openhab.core.library.types.OnOffType;
78 import org.openhab.core.library.types.PercentType;
79 import org.openhab.core.library.types.QuantityType;
80 import org.openhab.core.library.types.StringType;
81 import org.openhab.core.library.unit.Units;
82 import org.openhab.core.thing.ChannelUID;
83 import org.openhab.core.thing.Thing;
84 import org.openhab.core.thing.ThingStatus;
85 import org.openhab.core.thing.ThingStatusDetail;
86 import org.openhab.core.thing.binding.BaseThingHandler;
87 import org.openhab.core.thing.binding.ThingHandlerService;
88 import org.openhab.core.types.Command;
89 import org.openhab.core.types.RefreshType;
90 import org.openhab.core.types.State;
91 import org.slf4j.Logger;
92 import org.slf4j.LoggerFactory;
95 * The EmotivaProcessorHandler is responsible for handling OpenHAB commands, which are
96 * sent to one of the channels.
98 * @author Espen Fossen - Initial contribution
101 public class EmotivaProcessorHandler extends BaseThingHandler {
103 private final Logger logger = LoggerFactory.getLogger(EmotivaProcessorHandler.class);
105 private final Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
107 private final EmotivaConfiguration config;
110 * Emotiva devices have trouble with too many subscriptions in same request, so subscriptions are dividing into
111 * those general group channels, and the rest.
113 private final EmotivaSubscriptionTags[] generalSubscription = EmotivaSubscriptionTags.generalChannels();
114 private final EmotivaSubscriptionTags[] nonGeneralSubscriptions = EmotivaSubscriptionTags.nonGeneralChannels();
116 private final EnumMap<EmotivaControlCommands, String> sourcesMainZone;
117 private final EnumMap<EmotivaControlCommands, String> sourcesZone2;
118 private final EnumMap<EmotivaSubscriptionTags, String> modes;
119 private final Map<String, Map<EmotivaControlCommands, String>> commandMaps = new ConcurrentHashMap<>();
120 private final EmotivaTranslationProvider i18nProvider;
122 private @Nullable ScheduledFuture<?> pollingJob;
123 private @Nullable ScheduledFuture<?> connectRetryJob;
124 private @Nullable EmotivaUdpSendingService sendingService;
125 private @Nullable EmotivaUdpReceivingService notifyListener;
126 private @Nullable EmotivaUdpReceivingService menuNotifyListener;
128 private final int retryConnectInMinutes;
131 * Thread factory for menu progress bar
133 private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(BINDING_ID, true);
135 private final EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
137 private boolean udpSenderActive = false;
139 public EmotivaProcessorHandler(Thing thing, EmotivaTranslationProvider i18nProvider) throws JAXBException {
141 this.i18nProvider = i18nProvider;
142 this.config = getConfigAs(EmotivaConfiguration.class);
143 this.retryConnectInMinutes = config.retryConnectInMinutes;
145 sourcesMainZone = new EnumMap<>(EmotivaControlCommands.class);
146 commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
148 sourcesZone2 = new EnumMap<>(EmotivaControlCommands.class);
149 commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
151 EnumMap<EmotivaControlCommands, String> channels = new EnumMap<>(
152 Map.ofEntries(Map.entry(channel_1, channel_1.getLabel()),
153 Map.entry(EmotivaControlCommands.channel_2, EmotivaControlCommands.channel_2.getLabel()),
154 Map.entry(EmotivaControlCommands.channel_3, EmotivaControlCommands.channel_3.getLabel()),
155 Map.entry(EmotivaControlCommands.channel_4, EmotivaControlCommands.channel_4.getLabel()),
156 Map.entry(EmotivaControlCommands.channel_5, EmotivaControlCommands.channel_5.getLabel()),
157 Map.entry(EmotivaControlCommands.channel_6, EmotivaControlCommands.channel_6.getLabel()),
158 Map.entry(EmotivaControlCommands.channel_7, EmotivaControlCommands.channel_7.getLabel()),
159 Map.entry(EmotivaControlCommands.channel_8, EmotivaControlCommands.channel_8.getLabel()),
160 Map.entry(EmotivaControlCommands.channel_9, EmotivaControlCommands.channel_9.getLabel()),
161 Map.entry(EmotivaControlCommands.channel_10, EmotivaControlCommands.channel_10.getLabel()),
162 Map.entry(EmotivaControlCommands.channel_11, EmotivaControlCommands.channel_11.getLabel()),
163 Map.entry(EmotivaControlCommands.channel_12, EmotivaControlCommands.channel_12.getLabel()),
164 Map.entry(EmotivaControlCommands.channel_13, EmotivaControlCommands.channel_13.getLabel()),
165 Map.entry(EmotivaControlCommands.channel_14, EmotivaControlCommands.channel_14.getLabel()),
166 Map.entry(EmotivaControlCommands.channel_15, EmotivaControlCommands.channel_15.getLabel()),
167 Map.entry(EmotivaControlCommands.channel_16, EmotivaControlCommands.channel_16.getLabel()),
168 Map.entry(EmotivaControlCommands.channel_17, EmotivaControlCommands.channel_17.getLabel()),
169 Map.entry(EmotivaControlCommands.channel_18, EmotivaControlCommands.channel_18.getLabel()),
170 Map.entry(EmotivaControlCommands.channel_19, EmotivaControlCommands.channel_19.getLabel()),
171 Map.entry(EmotivaControlCommands.channel_20, EmotivaControlCommands.channel_20.getLabel())));
172 commandMaps.put(tuner_channel.getEmotivaName(), channels);
174 EnumMap<EmotivaControlCommands, String> bands = new EnumMap<>(
175 Map.of(band_am, band_am.getLabel(), band_fm, band_fm.getLabel()));
176 commandMaps.put(tuner_band.getEmotivaName(), bands);
178 modes = new EnumMap<>(EmotivaSubscriptionTags.class);
182 public void initialize() {
183 logger.debug("Initialize: '{}'", getThing().getUID());
184 updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "@text/message.processor.connecting");
185 if (config.controlPort < 0) {
186 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
187 "@text/message.processor.connection.error.port");
190 if (config.ipAddress.trim().isEmpty()) {
191 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
192 "@text/message.processor.connection.error.address-empty");
196 // noinspection ResultOfMethodCallIgnored
197 InetAddress.getByName(config.ipAddress);
198 } catch (UnknownHostException ignored) {
199 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
200 "@text/message.processor.connection.error.address-invalid");
205 scheduler.execute(this::connect);
208 private synchronized void connect() {
209 final EmotivaConfiguration localConfig = config;
211 final EmotivaUdpReceivingService notifyListener = new EmotivaUdpReceivingService(localConfig.notifyPort,
212 localConfig, scheduler);
213 this.notifyListener = notifyListener;
214 notifyListener.connect(this::handleStatusUpdate, true);
216 final EmotivaUdpSendingService sendConnector = new EmotivaUdpSendingService(localConfig, scheduler);
217 sendingService = sendConnector;
218 sendConnector.connect(this::handleStatusUpdate, true);
220 // Simple retry mechanism to handle minor network issues, if this fails a retry job is created
221 for (int attempt = 1; attempt <= DEFAULT_CONNECTION_RETRIES && !udpSenderActive; attempt++) {
223 logger.debug("Connection attempt '{}'", attempt);
224 sendConnector.sendSubscription(generalSubscription, config);
225 sendConnector.sendSubscription(nonGeneralSubscriptions, config);
226 } catch (IOException e) {
227 // network or socket failure, also wait 2 sec and try again
230 for (int delay = 0; delay < 10 && !udpSenderActive; delay++) {
231 Thread.sleep(200); // wait 10 x 200ms = 2sec
235 if (udpSenderActive) {
236 updateStatus(ThingStatus.ONLINE);
238 final EmotivaUdpReceivingService menuListenerConnector = new EmotivaUdpReceivingService(
239 localConfig.menuNotifyPort, localConfig, scheduler);
240 this.menuNotifyListener = menuListenerConnector;
241 menuListenerConnector.connect(this::handleStatusUpdate, true);
243 startPollingKeepAlive();
245 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
246 "@text/message.processor.connection.failed");
248 scheduleConnectRetry(retryConnectInMinutes);
250 } catch (InterruptedException e) {
251 // OH shutdown - don't log anything, Framework will call dispose()
252 } catch (Exception e) {
253 logger.error("Connection to '{}' failed", localConfig.ipAddress, e);
254 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
255 "@text/message.processor.connection.failed");
257 scheduleConnectRetry(retryConnectInMinutes);
261 private void scheduleConnectRetry(long waitMinutes) {
262 logger.debug("Scheduling connection retry in '{}' minutes", waitMinutes);
263 connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
267 * Starts a polling job for connection to th device, adds the
268 * {@link EmotivaBindingConstants#DEFAULT_KEEP_ALIVE_IN_MILLISECONDS} as a time buffer for checking, to avoid
269 * flapping state or minor network issues.
271 private void startPollingKeepAlive() {
272 final ScheduledFuture<?> localRefreshJob = this.pollingJob;
273 if (localRefreshJob == null || localRefreshJob.isCancelled()) {
274 logger.debug("Start polling");
276 int delay = stateMap.get(EmotivaSubscriptionTags.keepAlive.name()) != null
277 && stateMap.get(EmotivaSubscriptionTags.keepAlive.name()) instanceof Number keepAlive
278 ? keepAlive.intValue()
280 pollingJob = scheduler.scheduleWithFixedDelay(this::checkKeepAliveTimestamp,
281 delay + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS, delay + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS,
282 TimeUnit.MILLISECONDS);
286 private void checkKeepAliveTimestamp() {
287 if (ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
288 State state = stateMap.get(LAST_SEEN_STATE_NAME);
289 if (state instanceof Number value) {
290 Instant lastKeepAliveMessageTimestamp = Instant.ofEpochSecond(value.longValue());
291 Instant deviceGoneGracePeriod = Instant.now().minus(config.keepAlive, ChronoUnit.MILLIS)
292 .minus(DEFAULT_KEEP_ALIVE_CONSIDERED_LOST_IN_MILLISECONDS, ChronoUnit.MILLIS);
293 if (lastKeepAliveMessageTimestamp.isBefore(deviceGoneGracePeriod)) {
295 "Last KeepAlive message received '{}', over grace-period by '{}', consider '{}' gone, setting OFFLINE and disposing",
296 lastKeepAliveMessageTimestamp,
297 Duration.between(lastKeepAliveMessageTimestamp, deviceGoneGracePeriod),
298 thing.getThingTypeUID());
299 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
300 "@text/message.processor.connection.error.keep-alive");
301 // Connection lost, avoid sending unsubscription messages
302 udpSenderActive = false;
304 scheduleConnectRetry(retryConnectInMinutes);
307 } else if (ThingStatus.OFFLINE.equals(getThing().getStatusInfo().getStatus())) {
308 logger.debug("Keep alive pool job, '{}' is '{}'", getThing().getThingTypeUID(),
309 getThing().getStatusInfo().getStatus());
313 private void handleStatusUpdate(EmotivaUdpResponse emotivaUdpResponse) {
314 udpSenderActive = true;
315 logger.debug("Received data from '{}' with length '{}'", emotivaUdpResponse.ipAddress(),
316 emotivaUdpResponse.answer().length());
320 object = xmlUtils.unmarshallToEmotivaDTO(emotivaUdpResponse.answer());
321 } catch (JAXBException e) {
322 logger.debug("Could not unmarshal answer from '{}' with length '{}' and content '{}'",
323 emotivaUdpResponse.ipAddress(), emotivaUdpResponse.answer().length(), emotivaUdpResponse.answer(),
328 if (object instanceof EmotivaAckDTO answerDto) {
329 // Currently not supported to revert a failed command update, just used for logging for now.
330 logger.trace("Processing received '{}' with '{}'", EmotivaAckDTO.class.getSimpleName(), answerDto);
331 } else if (object instanceof EmotivaBarNotifyWrapper answerDto) {
332 logger.trace("Processing received '{}' with '{}'", EmotivaBarNotifyWrapper.class.getSimpleName(),
333 emotivaUdpResponse.answer());
335 List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
337 if (!emotivaBarNotifies.isEmpty()) {
338 if (emotivaBarNotifies.get(0).getType() != null) {
339 findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
343 } else if (object instanceof EmotivaNotifyWrapper answerDto) {
344 logger.trace("Processing received '{}' with '{}'", EmotivaNotifyWrapper.class.getSimpleName(),
345 emotivaUdpResponse.answer());
346 handleNotificationUpdate(answerDto);
347 } else if (object instanceof EmotivaUpdateResponse answerDto) {
348 logger.trace("Processing received '{}' with '{}'", EmotivaUpdateResponse.class.getSimpleName(),
349 emotivaUdpResponse.answer());
350 handleNotificationUpdate(answerDto);
351 } else if (object instanceof EmotivaMenuNotifyDTO answerDto) {
352 logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
353 emotivaUdpResponse.answer());
355 if (answerDto.getRow() != null) {
356 handleMenuNotify(answerDto);
357 } else if (answerDto.getProgress() != null && answerDto.getProgress().getTime() != null) {
358 logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
359 emotivaUdpResponse.answer());
360 listeningThreadFactory
361 .newThread(() -> handleMenuNotifyProgressMessage(answerDto.getProgress().getTime())).start();
363 } else if (object instanceof EmotivaSubscriptionResponse answerDto) {
364 logger.trace("Processing received '{}' with '{}'", EmotivaSubscriptionResponse.class.getSimpleName(),
365 emotivaUdpResponse.answer());
366 // Populates static input sources, except input
367 sourcesMainZone.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_MAIN_ZONE));
368 sourcesMainZone.remove(EmotivaControlCommands.input);
369 commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
371 sourcesZone2.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2));
372 sourcesZone2.remove(EmotivaControlCommands.zone2_input);
373 commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
375 if (answerDto.getProperties() == null) {
376 for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
377 handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
380 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
381 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
382 property.getStatus());
388 private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
389 String highlightValue = "";
391 for (var row = 4; row <= 6; row++) {
392 var emotivaMenuRow = answerDto.getRow().get(row);
393 logger.debug("Checking row '{}' with '{}' columns", row, emotivaMenuRow.getCol().size());
394 for (var column = 0; column <= 2; column++) {
395 var emotivaMenuCol = emotivaMenuRow.getCol().get(column);
396 String cellValue = "";
397 if (emotivaMenuCol.getValue() != null) {
398 cellValue = emotivaMenuCol.getValue();
401 if (emotivaMenuCol.getCheckbox() != null) {
402 cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
406 if (emotivaMenuCol.getHighlight() != null
407 && MENU_PANEL_HIGHLIGHTED.equalsIgnoreCase(emotivaMenuCol.getHighlight().trim())) {
408 logger.debug("Highlight is at row '{}' column '{}' value '{}'", row, column, cellValue);
409 highlightValue = cellValue;
412 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
413 getMenuPanelColumnLabel(column));
414 updateChannelState(channelName, new StringType(cellValue));
417 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
420 private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
422 var seconds = Integer.parseInt(progressBarTimeInSeconds);
423 for (var count = 0; seconds >= count; count++) {
424 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT,
425 new StringType(updateProgress(EmotivaCommandHelper.integerToPercentage(count))));
427 } catch (NumberFormatException e) {
428 logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
432 private void resetMenuPanelChannels() {
433 logger.debug("Resetting Menu Panel Display");
434 for (var row = 4; row <= 6; row++) {
435 for (var column = 0; column <= 2; column++) {
436 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
437 getMenuPanelColumnLabel(column));
438 updateChannelState(channelName, new StringType(""));
441 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
444 private void sendEmotivaUpdate(EmotivaControlCommands tags) {
445 EmotivaUdpSendingService localSendingService = sendingService;
446 if (localSendingService != null) {
448 localSendingService.sendUpdate(tags, config);
449 } catch (InterruptedIOException e) {
450 logger.debug("Interrupted during sending of EmotivaUpdate message to device '{}'",
451 this.getThing().getThingTypeUID(), e);
452 } catch (IOException e) {
453 logger.error("Failed to send EmotivaUpdate message to device '{}'", this.getThing().getThingTypeUID(),
459 private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
460 if (answerDto.getProperties() == null) {
461 for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
463 EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
464 if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
465 findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
466 tagName.getDataType());
468 } catch (IllegalArgumentException e) {
469 logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
473 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
474 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
475 property.getStatus());
480 private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
481 logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
483 if (status.equals(NOT_VALID.name())) {
484 logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
488 if ("None".equals(value)) {
489 logger.debug("No value present for channel {}, usually means a speaker is not enabled",
490 emotivaSubscriptionName);
495 EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
496 } catch (IllegalArgumentException e) {
497 logger.debug("Subscription property '{}' is not know to the binding, might need updating",
498 emotivaSubscriptionName);
502 if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
503 logger.debug("Initial subscription status update for {}, skipping, only want notifications",
504 emotivaSubscriptionName);
509 EmotivaSubscriptionTags subscriptionTag;
511 subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
512 } catch (IllegalArgumentException e) {
513 logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
517 if (subscriptionTag.getChannel().isEmpty()) {
518 logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
519 emotivaSubscriptionName);
523 String trimmedValue = value.trim();
525 logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
528 // Add/Update user assigned name for inputs
529 if (subscriptionTag.getChannel().startsWith(CHANNEL_INPUT1.substring(0, CHANNEL_INPUT1.indexOf("-") + 1))
530 && "true".equals(visible)) {
531 logger.debug("Adding '{}' to dynamic source input list", trimmedValue);
532 sourcesMainZone.put(EmotivaControlCommands.matchToInput(subscriptionTag.name()), trimmedValue);
533 commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
535 logger.debug("sources list is now {}", sourcesMainZone.size());
538 // Add/Update audio modes
539 if (subscriptionTag.getChannel().startsWith(CHANNEL_MODE + "-") && "true".equals(visible)) {
540 String modeName = i18nProvider.getText("channel-type.emotiva.selected-mode.option."
541 + subscriptionTag.getChannel().substring(subscriptionTag.getChannel().indexOf("_") + 1));
542 logger.debug("Adding '{} ({})' from channel '{}' to dynamic mode list", trimmedValue, modeName,
543 subscriptionTag.getChannel());
544 modes.put(EmotivaSubscriptionTags.fromChannelUID(subscriptionTag.getChannel()), trimmedValue);
547 findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
548 subscriptionTag.getDataType());
549 } catch (IllegalArgumentException e) {
550 logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
554 private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
556 case DIMENSIONLESS_DECIBEL -> {
557 var trimmedString = value.replaceAll("[ +]", "");
558 logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
560 if (channelName.equals(CHANNEL_MAIN_VOLUME)) {
561 updateVolumeChannels(trimmedString, CHANNEL_MUTE, channelName, CHANNEL_MAIN_VOLUME_DB);
562 } else if (channelName.equals(CHANNEL_ZONE2_VOLUME)) {
563 updateVolumeChannels(trimmedString, CHANNEL_ZONE2_MUTE, channelName, CHANNEL_ZONE2_VOLUME_DB);
565 if (trimmedString.equals("None")) {
566 updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
568 updateChannelState(channelName,
569 QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
573 case DIMENSIONLESS_PERCENT -> {
574 var trimmedString = value.replaceAll("[ +]", "");
575 logger.debug("Update channel '{}' to '{}:{}'", channelName, PercentType.class.getSimpleName(), value);
576 updateChannelState(channelName, PercentType.valueOf(trimmedString));
578 case FREQUENCY_HERTZ -> {
579 logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
581 if (!value.isEmpty()) {
582 // Getting rid of characters and empty space leaves us with the raw frequency
584 String frequencyString = value.replaceAll("[a-zA-Z ]", "");
585 QuantityType<Frequency> hz = QuantityType.valueOf(0, Units.HERTZ);
586 if (value.contains("AM")) {
587 hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000, Units.HERTZ);
588 } else if (value.contains("FM")) {
589 hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000000, Units.HERTZ);
591 updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
592 } catch (NumberFormatException e) {
593 logger.debug("Could not extract radio tuner frequency from '{}'", value);
599 "Received goodbye notification from '{}'; disconnecting and scheduling av connection retry in '{}' minutes",
600 getThing().getUID(), DEFAULT_RETRY_INTERVAL_MINUTES);
601 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/message.processor.goodbye");
603 // Device gone, sending unsubscription messages not needed
604 udpSenderActive = false;
606 scheduleConnectRetry(retryConnectInMinutes);
608 case NUMBER_TIME -> {
609 logger.debug("Update channel '{}' to '{}:{}'", channelName, Number.class.getSimpleName(), value);
610 long nowEpochSecond = Instant.now().getEpochSecond();
611 updateChannelState(channelName, new QuantityType<>(nowEpochSecond, Units.SECOND));
614 logger.debug("Update channel '{}' to '{}:{}'", channelName, OnOffType.class.getSimpleName(), value);
615 OnOffType switchValue = OnOffType.from(value.trim().toUpperCase());
616 updateChannelState(channelName, switchValue);
617 if (switchValue.equals(OnOffType.OFF) && CHANNEL_MENU.equals(channelName)) {
618 resetMenuPanelChannels();
622 logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
623 updateChannelState(channelName, StringType.valueOf(value));
625 case UNKNOWN -> // Do nothing, types not connect to channels
626 logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
628 // datatypes not connect to a channel, so do nothing
633 private void updateChannelState(String channelID, State state) {
634 stateMap.put(channelID, state);
635 logger.trace("Updating channel '{}' with '{}'", channelID, state);
636 updateState(channelID, state);
639 private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
640 if ("Mute".equals(value)) {
641 updateChannelState(muteChannel, OnOffType.ON);
643 updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
644 updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
649 public void handleCommand(ChannelUID channelUID, Command ohCommand) {
650 logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
651 EmotivaUdpSendingService localSendingService = sendingService;
653 if (localSendingService != null) {
654 EmotivaControlRequest emotivaRequest = channelToControlRequest(channelUID.getId(), commandMaps,
655 protocolFromConfig(config.protocolVersion));
656 if (ohCommand instanceof RefreshType) {
657 stateMap.remove(channelUID.getId());
659 if (emotivaRequest.getDefaultCommand().equals(none)) {
660 logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
661 emotivaRequest.getName(), channelUID);
663 logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
664 sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
668 EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, stateMap.get(channelUID.getId()));
669 localSendingService.send(dto);
671 if (emotivaRequest.getName().equals(EmotivaControlCommands.volume.name())) {
672 if (ohCommand instanceof PercentType value) {
673 updateChannelState(CHANNEL_MAIN_VOLUME_DB,
674 QuantityType.valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
675 } else if (ohCommand instanceof QuantityType<?> value) {
676 updateChannelState(CHANNEL_MAIN_VOLUME, volumeDecibelToPercentage(value.toString()));
678 } else if (emotivaRequest.getName().equals(EmotivaControlCommands.zone2_volume.name())) {
679 if (ohCommand instanceof PercentType value) {
680 updateChannelState(CHANNEL_ZONE2_VOLUME_DB,
681 QuantityType.valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
682 } else if (ohCommand instanceof QuantityType<?> value) {
683 updateChannelState(CHANNEL_ZONE2_VOLUME, volumeDecibelToPercentage(value.toString()));
685 } else if (ohCommand instanceof OnOffType value) {
686 if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
687 localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
690 } catch (InterruptedIOException e) {
691 logger.debug("Interrupted during updating state for channel: '{}:{}:{}'", channelUID.getId(),
692 emotivaRequest.getName(), emotivaRequest.getDataType(), e);
693 } catch (IOException e) {
694 logger.error("Failed updating state for channel '{}:{}:{}'", channelUID.getId(),
695 emotivaRequest.getName(), emotivaRequest.getDataType(), e);
702 public void dispose() {
703 logger.debug("Disposing '{}'", getThing().getUID());
709 private synchronized void disconnect() {
710 final EmotivaUdpSendingService localSendingService = sendingService;
711 if (localSendingService != null) {
712 logger.debug("Disposing active sender");
713 if (udpSenderActive) {
715 // Unsubscribe before disconnect
716 localSendingService.sendUnsubscribe(generalSubscription);
717 localSendingService.sendUnsubscribe(nonGeneralSubscriptions);
718 } catch (IOException e) {
719 logger.debug("Failed to unsubscribe for '{}'", config.ipAddress, e);
723 sendingService = null;
725 localSendingService.disconnect();
726 logger.debug("Disconnected udp send connector");
727 } catch (Exception e) {
728 logger.debug("Failed to close socket connection for '{}'", config.ipAddress, e);
731 udpSenderActive = false;
733 final EmotivaUdpReceivingService notifyConnector = notifyListener;
734 if (notifyConnector != null) {
735 notifyListener = null;
737 notifyConnector.disconnect();
738 logger.debug("Disconnected notify connector");
739 } catch (Exception e) {
740 logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
744 final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
745 if (menuConnector != null) {
746 menuNotifyListener = null;
748 menuConnector.disconnect();
749 logger.debug("Disconnected menu notify connector");
750 } catch (Exception e) {
751 logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
755 ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
756 if (localConnectRetryJob != null) {
757 localConnectRetryJob.cancel(true);
758 this.connectRetryJob = null;
761 ScheduledFuture<?> localPollingJob = this.pollingJob;
762 if (localPollingJob != null) {
763 localPollingJob.cancel(true);
764 this.pollingJob = null;
765 logger.debug("Polling job canceled");
770 public Collection<Class<? extends ThingHandlerService>> getServices() {
771 return Set.of(InputStateOptionProvider.class);
774 public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
775 return sourcesMainZone;
778 public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
782 public EnumMap<EmotivaSubscriptionTags, String> getModes() {