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);
332 } else if (object instanceof EmotivaBarNotifyWrapper answerDto) {
333 logger.trace("Processing received '{}' with '{}'", EmotivaBarNotifyWrapper.class.getSimpleName(),
334 emotivaUdpResponse.answer());
336 List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
338 if (!emotivaBarNotifies.isEmpty()) {
339 if (emotivaBarNotifies.get(0).getType() != null) {
340 findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
344 } else if (object instanceof EmotivaNotifyWrapper answerDto) {
345 logger.trace("Processing received '{}' with '{}'", EmotivaNotifyWrapper.class.getSimpleName(),
346 emotivaUdpResponse.answer());
347 handleNotificationUpdate(answerDto);
348 } else if (object instanceof EmotivaUpdateResponse answerDto) {
349 logger.trace("Processing received '{}' with '{}'", EmotivaUpdateResponse.class.getSimpleName(),
350 emotivaUdpResponse.answer());
351 handleNotificationUpdate(answerDto);
352 } else if (object instanceof EmotivaMenuNotifyDTO answerDto) {
353 logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
354 emotivaUdpResponse.answer());
356 if (answerDto.getRow() != null) {
357 handleMenuNotify(answerDto);
358 } else if (answerDto.getProgress() != null && answerDto.getProgress().getTime() != null) {
359 logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
360 emotivaUdpResponse.answer());
361 listeningThreadFactory
362 .newThread(() -> handleMenuNotifyProgressMessage(answerDto.getProgress().getTime())).start();
364 } else if (object instanceof EmotivaSubscriptionResponse answerDto) {
365 logger.trace("Processing received '{}' with '{}'", EmotivaSubscriptionResponse.class.getSimpleName(),
366 emotivaUdpResponse.answer());
367 // Populates static input sources, except input
368 sourcesMainZone.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_MAIN_ZONE));
369 sourcesMainZone.remove(EmotivaControlCommands.input);
370 commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
372 sourcesZone2.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2));
373 sourcesZone2.remove(EmotivaControlCommands.zone2_input);
374 commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
376 if (answerDto.getProperties() == null) {
377 for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
378 handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
381 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
382 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
383 property.getStatus());
389 private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
390 String highlightValue = "";
392 for (var row = 4; row <= 6; row++) {
393 var emotivaMenuRow = answerDto.getRow().get(row);
394 logger.debug("Checking row '{}' with '{}' columns", row, emotivaMenuRow.getCol().size());
395 for (var column = 0; column <= 2; column++) {
396 var emotivaMenuCol = emotivaMenuRow.getCol().get(column);
397 String cellValue = "";
398 if (emotivaMenuCol.getValue() != null) {
399 cellValue = emotivaMenuCol.getValue();
402 if (emotivaMenuCol.getCheckbox() != null) {
403 cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
407 if (emotivaMenuCol.getHighlight() != null
408 && MENU_PANEL_HIGHLIGHTED.equalsIgnoreCase(emotivaMenuCol.getHighlight().trim())) {
409 logger.debug("Highlight is at row '{}' column '{}' value '{}'", row, column, cellValue);
410 highlightValue = cellValue;
413 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
414 getMenuPanelColumnLabel(column));
415 updateChannelState(channelName, new StringType(cellValue));
418 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
421 private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
423 var seconds = Integer.parseInt(progressBarTimeInSeconds);
424 for (var count = 0; seconds >= count; count++) {
425 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT,
426 new StringType(updateProgress(EmotivaCommandHelper.integerToPercentage(count))));
428 } catch (NumberFormatException e) {
429 logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
433 private void resetMenuPanelChannels() {
434 logger.debug("Resetting Menu Panel Display");
435 for (var row = 4; row <= 6; row++) {
436 for (var column = 0; column <= 2; column++) {
437 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
438 getMenuPanelColumnLabel(column));
439 updateChannelState(channelName, new StringType(""));
442 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
445 private void sendEmotivaUpdate(EmotivaControlCommands tags) {
446 EmotivaUdpSendingService localSendingService = sendingService;
447 if (localSendingService != null) {
449 localSendingService.sendUpdate(tags, config);
450 } catch (InterruptedIOException e) {
451 logger.debug("Interrupted during sending of EmotivaUpdate message to device '{}'",
452 this.getThing().getThingTypeUID(), e);
453 } catch (IOException e) {
454 logger.error("Failed to send EmotivaUpdate message to device '{}'", this.getThing().getThingTypeUID(),
460 private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
461 if (answerDto.getProperties() == null) {
462 for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
464 EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
465 if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
466 findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
467 tagName.getDataType());
469 } catch (IllegalArgumentException e) {
470 logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
474 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
475 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
476 property.getStatus());
481 private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
482 logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
484 if (status.equals(NOT_VALID.name())) {
485 logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
489 if ("None".equals(value)) {
490 logger.debug("No value present for channel {}, usually means a speaker is not enabled",
491 emotivaSubscriptionName);
496 EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
497 } catch (IllegalArgumentException e) {
498 logger.debug("Subscription property '{}' is not know to the binding, might need updating",
499 emotivaSubscriptionName);
503 if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
504 logger.debug("Initial subscription status update for {}, skipping, only want notifications",
505 emotivaSubscriptionName);
510 EmotivaSubscriptionTags subscriptionTag;
512 subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
513 } catch (IllegalArgumentException e) {
514 logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
518 if (subscriptionTag.getChannel().isEmpty()) {
519 logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
520 emotivaSubscriptionName);
524 String trimmedValue = value.trim();
526 logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
529 // Add/Update user assigned name for inputs
530 if (subscriptionTag.getChannel().startsWith(CHANNEL_INPUT1.substring(0, CHANNEL_INPUT1.indexOf("-") + 1))
531 && "true".equals(visible)) {
532 logger.debug("Adding '{}' to dynamic source input list", trimmedValue);
533 sourcesMainZone.put(EmotivaControlCommands.matchToInput(subscriptionTag.name()), trimmedValue);
534 commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
536 logger.debug("sources list is now {}", sourcesMainZone.size());
539 // Add/Update audio modes
540 if (subscriptionTag.getChannel().startsWith(CHANNEL_MODE + "-") && "true".equals(visible)) {
541 String modeName = i18nProvider.getText("channel-type.emotiva.selected-mode.option."
542 + subscriptionTag.getChannel().substring(subscriptionTag.getChannel().indexOf("_") + 1));
543 logger.debug("Adding '{} ({})' from channel '{}' to dynamic mode list", trimmedValue, modeName,
544 subscriptionTag.getChannel());
545 modes.put(EmotivaSubscriptionTags.fromChannelUID(subscriptionTag.getChannel()), trimmedValue);
548 findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
549 subscriptionTag.getDataType());
550 } catch (IllegalArgumentException e) {
551 logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
555 private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
557 case DIMENSIONLESS_DECIBEL -> {
558 var trimmedString = value.replaceAll("[ +]", "");
559 logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
561 if (channelName.equals(CHANNEL_MAIN_VOLUME)) {
562 updateVolumeChannels(trimmedString, CHANNEL_MUTE, channelName, CHANNEL_MAIN_VOLUME_DB);
563 } else if (channelName.equals(CHANNEL_ZONE2_VOLUME)) {
564 updateVolumeChannels(trimmedString, CHANNEL_ZONE2_MUTE, channelName, CHANNEL_ZONE2_VOLUME_DB);
566 if (trimmedString.equals("None")) {
567 updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
569 updateChannelState(channelName,
570 QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
574 case DIMENSIONLESS_PERCENT -> {
575 var trimmedString = value.replaceAll("[ +]", "");
576 logger.debug("Update channel '{}' to '{}:{}'", channelName, PercentType.class.getSimpleName(), value);
577 updateChannelState(channelName, PercentType.valueOf(trimmedString));
579 case FREQUENCY_HERTZ -> {
580 logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
582 if (!value.isEmpty()) {
583 // Getting rid of characters and empty space leaves us with the raw frequency
585 String frequencyString = value.replaceAll("[a-zA-Z ]", "");
586 QuantityType<Frequency> hz = QuantityType.valueOf(0, Units.HERTZ);
587 if (value.contains("AM")) {
588 hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000, Units.HERTZ);
589 } else if (value.contains("FM")) {
590 hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000000, Units.HERTZ);
592 updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
593 } catch (NumberFormatException e) {
594 logger.debug("Could not extract radio tuner frequency from '{}'", value);
600 "Received goodbye notification from '{}'; disconnecting and scheduling av connection retry in '{}' minutes",
601 getThing().getUID(), DEFAULT_RETRY_INTERVAL_MINUTES);
602 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/message.processor.goodbye");
604 // Device gone, sending unsubscription messages not needed
605 udpSenderActive = false;
607 scheduleConnectRetry(retryConnectInMinutes);
609 case NUMBER_TIME -> {
610 logger.debug("Update channel '{}' to '{}:{}'", channelName, Number.class.getSimpleName(), value);
611 long nowEpochSecond = Instant.now().getEpochSecond();
612 updateChannelState(channelName, new QuantityType<>(nowEpochSecond, Units.SECOND));
615 logger.debug("Update channel '{}' to '{}:{}'", channelName, OnOffType.class.getSimpleName(), value);
616 OnOffType switchValue = OnOffType.from(value.trim().toUpperCase());
617 updateChannelState(channelName, switchValue);
618 if (switchValue.equals(OnOffType.OFF) && CHANNEL_MENU.equals(channelName)) {
619 resetMenuPanelChannels();
623 logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
624 updateChannelState(channelName, StringType.valueOf(value));
626 case UNKNOWN -> // Do nothing, types not connect to channels
627 logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
629 // datatypes not connect to a channel, so do nothing
634 private void updateChannelState(String channelID, State state) {
635 stateMap.put(channelID, state);
636 logger.trace("Updating channel '{}' with '{}'", channelID, state);
637 updateState(channelID, state);
640 private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
641 if ("Mute".equals(value)) {
642 updateChannelState(muteChannel, OnOffType.ON);
644 updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
645 updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
650 public void handleCommand(ChannelUID channelUID, Command ohCommand) {
651 logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
652 EmotivaUdpSendingService localSendingService = sendingService;
654 if (localSendingService != null) {
655 EmotivaControlRequest emotivaRequest = channelToControlRequest(channelUID.getId(), commandMaps,
656 protocolFromConfig(config.protocolVersion));
657 if (ohCommand instanceof RefreshType) {
658 stateMap.remove(channelUID.getId());
660 if (emotivaRequest.getDefaultCommand().equals(none)) {
661 logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
662 emotivaRequest.getName(), channelUID);
664 logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
665 sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
669 EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, stateMap.get(channelUID.getId()));
670 localSendingService.send(dto);
672 if (emotivaRequest.getName().equals(EmotivaControlCommands.volume.name())) {
673 if (ohCommand instanceof PercentType value) {
674 updateChannelState(CHANNEL_MAIN_VOLUME_DB,
675 QuantityType.valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
676 } else if (ohCommand instanceof QuantityType<?> value) {
677 updateChannelState(CHANNEL_MAIN_VOLUME, volumeDecibelToPercentage(value.toString()));
679 } else if (emotivaRequest.getName().equals(EmotivaControlCommands.zone2_volume.name())) {
680 if (ohCommand instanceof PercentType value) {
681 updateChannelState(CHANNEL_ZONE2_VOLUME_DB,
682 QuantityType.valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
683 } else if (ohCommand instanceof QuantityType<?> value) {
684 updateChannelState(CHANNEL_ZONE2_VOLUME, volumeDecibelToPercentage(value.toString()));
686 } else if (ohCommand instanceof OnOffType value) {
687 if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
688 localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
691 } catch (InterruptedIOException e) {
692 logger.debug("Interrupted during updating state for channel: '{}:{}:{}'", channelUID.getId(),
693 emotivaRequest.getName(), emotivaRequest.getDataType(), e);
694 } catch (IOException e) {
695 logger.error("Failed updating state for channel '{}:{}:{}'", channelUID.getId(),
696 emotivaRequest.getName(), emotivaRequest.getDataType(), e);
703 public void dispose() {
704 logger.debug("Disposing '{}'", getThing().getUID());
710 private synchronized void disconnect() {
711 final EmotivaUdpSendingService localSendingService = sendingService;
712 if (localSendingService != null) {
713 logger.debug("Disposing active sender");
714 if (udpSenderActive) {
716 // Unsubscribe before disconnect
717 localSendingService.sendUnsubscribe(generalSubscription);
718 localSendingService.sendUnsubscribe(nonGeneralSubscriptions);
719 } catch (IOException e) {
720 logger.debug("Failed to unsubscribe for '{}'", config.ipAddress, e);
724 sendingService = null;
726 localSendingService.disconnect();
727 logger.debug("Disconnected udp send connector");
728 } catch (Exception e) {
729 logger.debug("Failed to close socket connection for '{}'", config.ipAddress, e);
732 udpSenderActive = false;
734 final EmotivaUdpReceivingService notifyConnector = notifyListener;
735 if (notifyConnector != null) {
736 notifyListener = null;
738 notifyConnector.disconnect();
739 logger.debug("Disconnected notify connector");
740 } catch (Exception e) {
741 logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
745 final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
746 if (menuConnector != null) {
747 menuNotifyListener = null;
749 menuConnector.disconnect();
750 logger.debug("Disconnected menu notify connector");
751 } catch (Exception e) {
752 logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
756 ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
757 if (localConnectRetryJob != null) {
758 localConnectRetryJob.cancel(true);
759 this.connectRetryJob = null;
762 ScheduledFuture<?> localPollingJob = this.pollingJob;
763 if (localPollingJob != null) {
764 localPollingJob.cancel(true);
765 this.pollingJob = null;
766 logger.debug("Polling job canceled");
771 public Collection<Class<? extends ThingHandlerService>> getServices() {
772 return Set.of(InputStateOptionProvider.class);
775 public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
776 return sourcesMainZone;
779 public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
783 public EnumMap<EmotivaSubscriptionTags, String> getModes() {