]> git.basschouten.com Git - openhab-addons.git/blob
191a96f1350002875a6bd68190b1e87ecc7951cf
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.emotiva.internal;
14
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.none;
24 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.power_on;
25 import static org.openhab.binding.emotiva.internal.protocol.EmotivaDataType.STRING;
26 import static org.openhab.binding.emotiva.internal.protocol.EmotivaPropertyStatus.NOT_VALID;
27 import static org.openhab.binding.emotiva.internal.protocol.EmotivaProtocolVersion.protocolFromConfig;
28 import static org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags.noSubscriptionToChannel;
29
30 import java.io.IOException;
31 import java.io.InterruptedIOException;
32 import java.net.InetAddress;
33 import java.net.UnknownHostException;
34 import java.time.Duration;
35 import java.time.Instant;
36 import java.time.temporal.ChronoUnit;
37 import java.util.Collection;
38 import java.util.EnumMap;
39 import java.util.List;
40 import java.util.Optional;
41 import java.util.Set;
42 import java.util.concurrent.ScheduledFuture;
43 import java.util.concurrent.TimeUnit;
44
45 import javax.measure.quantity.Frequency;
46 import javax.xml.bind.JAXBException;
47
48 import org.eclipse.jdt.annotation.NonNullByDefault;
49 import org.eclipse.jdt.annotation.Nullable;
50 import org.openhab.binding.emotiva.internal.dto.AbstractNotificationDTO;
51 import org.openhab.binding.emotiva.internal.dto.EmotivaAckDTO;
52 import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyDTO;
53 import org.openhab.binding.emotiva.internal.dto.EmotivaBarNotifyWrapper;
54 import org.openhab.binding.emotiva.internal.dto.EmotivaControlDTO;
55 import org.openhab.binding.emotiva.internal.dto.EmotivaMenuNotifyDTO;
56 import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyDTO;
57 import org.openhab.binding.emotiva.internal.dto.EmotivaNotifyWrapper;
58 import org.openhab.binding.emotiva.internal.dto.EmotivaPropertyDTO;
59 import org.openhab.binding.emotiva.internal.dto.EmotivaSubscriptionResponse;
60 import org.openhab.binding.emotiva.internal.dto.EmotivaUpdateResponse;
61 import org.openhab.binding.emotiva.internal.protocol.EmotivaCommandType;
62 import org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands;
63 import org.openhab.binding.emotiva.internal.protocol.EmotivaControlRequest;
64 import org.openhab.binding.emotiva.internal.protocol.EmotivaDataType;
65 import org.openhab.binding.emotiva.internal.protocol.EmotivaSubscriptionTags;
66 import org.openhab.binding.emotiva.internal.protocol.EmotivaUdpResponse;
67 import org.openhab.binding.emotiva.internal.protocol.EmotivaXmlUtils;
68 import org.openhab.core.common.NamedThreadFactory;
69 import org.openhab.core.library.types.DecimalType;
70 import org.openhab.core.library.types.OnOffType;
71 import org.openhab.core.library.types.PercentType;
72 import org.openhab.core.library.types.QuantityType;
73 import org.openhab.core.library.types.StringType;
74 import org.openhab.core.library.unit.Units;
75 import org.openhab.core.thing.ChannelUID;
76 import org.openhab.core.thing.Thing;
77 import org.openhab.core.thing.ThingStatus;
78 import org.openhab.core.thing.ThingStatusDetail;
79 import org.openhab.core.thing.binding.BaseThingHandler;
80 import org.openhab.core.thing.binding.ThingHandlerService;
81 import org.openhab.core.types.Command;
82 import org.openhab.core.types.RefreshType;
83 import org.openhab.core.types.State;
84 import org.slf4j.Logger;
85 import org.slf4j.LoggerFactory;
86
87 /**
88  * The EmotivaProcessorHandler is responsible for handling OpenHAB commands, which are
89  * sent to one of the channels.
90  *
91  * @author Espen Fossen - Initial contribution
92  */
93 @NonNullByDefault
94 public class EmotivaProcessorHandler extends BaseThingHandler {
95
96     private final Logger logger = LoggerFactory.getLogger(EmotivaProcessorHandler.class);
97
98     private final EmotivaConfiguration config;
99
100     /**
101      * Emotiva devices have trouble with too many subscriptions in same request, so subscriptions are dividing into
102      * groups.
103      */
104     private final List<EmotivaSubscriptionTags> generalSubscription = EmotivaSubscriptionTags.channels("general");
105     private final List<EmotivaSubscriptionTags> mainZoneSubscriptions = EmotivaSubscriptionTags.channels("main-zone");
106     private final List<EmotivaSubscriptionTags> zone2Subscriptions = EmotivaSubscriptionTags.channels("zone2");
107
108     private final EmotivaProcessorState state = new EmotivaProcessorState();
109     private final EmotivaTranslationProvider i18nProvider;
110
111     private @Nullable ScheduledFuture<?> pollingJob;
112     private @Nullable ScheduledFuture<?> connectRetryJob;
113     private @Nullable EmotivaUdpSendingService sendingService;
114     private @Nullable EmotivaUdpReceivingService notifyListener;
115     private @Nullable EmotivaUdpReceivingService menuNotifyListener;
116
117     private final int retryConnectInMinutes;
118
119     /**
120      * Thread factory for menu progress bar
121      */
122     private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(BINDING_ID, true);
123
124     private final EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
125
126     private boolean udpSenderActive = false;
127
128     public EmotivaProcessorHandler(Thing thing, EmotivaTranslationProvider i18nProvider) throws JAXBException {
129         super(thing);
130         this.i18nProvider = i18nProvider;
131         this.config = getConfigAs(EmotivaConfiguration.class);
132         this.retryConnectInMinutes = config.retryConnectInMinutes;
133     }
134
135     @Override
136     public void initialize() {
137         logger.debug("Initialize: '{}'", getThing().getUID());
138         updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "@text/message.processor.connecting");
139         if (config.controlPort < 0) {
140             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
141                     "@text/message.processor.connection.error.port");
142             return;
143         }
144         if (config.ipAddress.trim().isEmpty()) {
145             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
146                     "@text/message.processor.connection.error.address-empty");
147             return;
148         } else {
149             try {
150                 // noinspection ResultOfMethodCallIgnored
151                 InetAddress.getByName(config.ipAddress);
152             } catch (UnknownHostException ignored) {
153                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
154                         "@text/message.processor.connection.error.address-invalid");
155                 return;
156             }
157         }
158
159         scheduler.execute(this::connect);
160     }
161
162     private synchronized void connect() {
163         final EmotivaConfiguration localConfig = config;
164         try {
165             final EmotivaUdpReceivingService notifyListener = new EmotivaUdpReceivingService(localConfig.notifyPort,
166                     localConfig, scheduler);
167             this.notifyListener = notifyListener;
168             notifyListener.connect(this::handleStatusUpdate, true);
169
170             final EmotivaUdpSendingService sendConnector = new EmotivaUdpSendingService(localConfig, scheduler);
171             sendingService = sendConnector;
172             sendConnector.connect(this::handleStatusUpdate, true);
173
174             // Simple retry mechanism to handle minor network issues, if this fails a retry job is created
175             for (int attempt = 1; attempt <= DEFAULT_CONNECTION_RETRIES && !udpSenderActive; attempt++) {
176                 try {
177                     logger.debug("Connection attempt '{}'", attempt);
178                     sendConnector.sendSubscription(generalSubscription, config);
179                     sendConnector.sendSubscription(mainZoneSubscriptions, config);
180                     sendConnector.sendSubscription(zone2Subscriptions, config);
181                 } catch (IOException e) {
182                     // network or socket failure, also wait 2 sec and try again
183                 }
184
185                 for (int delay = 0; delay < 10 && !udpSenderActive; delay++) {
186                     Thread.sleep(200); // wait 10 x 200ms = 2sec
187                 }
188             }
189
190             if (udpSenderActive) {
191                 updateStatus(ThingStatus.ONLINE);
192
193                 final EmotivaUdpReceivingService menuListenerConnector = new EmotivaUdpReceivingService(
194                         localConfig.menuNotifyPort, localConfig, scheduler);
195                 this.menuNotifyListener = menuListenerConnector;
196                 menuListenerConnector.connect(this::handleStatusUpdate, true);
197
198                 startPollingKeepAlive();
199             } else {
200                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
201                         "@text/message.processor.connection.failed");
202                 disconnect();
203                 scheduleConnectRetry(retryConnectInMinutes);
204             }
205         } catch (InterruptedException e) {
206             // OH shutdown - don't log anything, Framework will call dispose()
207         } catch (Exception e) {
208             logger.error("Connection to '{}' failed", localConfig.ipAddress, e);
209             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
210                     "@text/message.processor.connection.failed");
211             disconnect();
212             scheduleConnectRetry(retryConnectInMinutes);
213         }
214     }
215
216     private void scheduleConnectRetry(long waitMinutes) {
217         logger.debug("Scheduling connection retry in '{}' minutes", waitMinutes);
218         connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
219     }
220
221     /**
222      * Starts a polling job for connection to the device, adds the
223      * {@link EmotivaBindingConstants#DEFAULT_KEEP_ALIVE_IN_MILLISECONDS} as a time buffer for checking, to avoid
224      * flapping state or minor network issues.
225      */
226     private void startPollingKeepAlive() {
227         final ScheduledFuture<?> localRefreshJob = this.pollingJob;
228         if (localRefreshJob == null || localRefreshJob.isCancelled()) {
229
230             Number keepAliveConfig = state.getChannel(EmotivaSubscriptionTags.keepAlive)
231                     .filter(channel -> channel instanceof Number).map(keepAlive -> (Number) keepAlive)
232                     .orElse(new DecimalType(config.keepAlive));
233
234             // noinspection ConstantConditions
235             long delay = keepAliveConfig == null
236                     ? DEFAULT_KEEP_ALIVE_IN_MILLISECONDS + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS
237                     : keepAliveConfig.longValue() + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS;
238             pollingJob = scheduler.scheduleWithFixedDelay(this::checkKeepAliveTimestamp, delay, delay,
239                     TimeUnit.MILLISECONDS);
240             logger.debug("Started scheduled job to check connection to device, with an {}ms internal", delay);
241         }
242     }
243
244     private void checkKeepAliveTimestamp() {
245         if (ThingStatus.ONLINE.equals(getThing().getStatusInfo().getStatus())) {
246             Optional<State> lastSeenState = state.getChannel(LAST_SEEN_STATE_NAME);
247             if (lastSeenState.isPresent()) {
248                 if (lastSeenState.get() instanceof Number value) {
249                     Instant lastKeepAliveMessageTimestamp = Instant.ofEpochSecond(value.longValue());
250                     Instant deviceGoneGracePeriod = Instant.now().minus(config.keepAlive, ChronoUnit.MILLIS)
251                             .minus(DEFAULT_KEEP_ALIVE_CONSIDERED_LOST_IN_MILLISECONDS, ChronoUnit.MILLIS);
252                     if (lastKeepAliveMessageTimestamp.isBefore(deviceGoneGracePeriod)) {
253                         logger.debug(
254                                 "Last KeepAlive message received '{}', over grace-period by '{}', consider '{}' gone, setting OFFLINE and disposing",
255                                 lastKeepAliveMessageTimestamp,
256                                 Duration.between(lastKeepAliveMessageTimestamp, deviceGoneGracePeriod),
257                                 thing.getThingTypeUID());
258                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
259                                 "@text/message.processor.connection.error.keep-alive");
260                         // Connection lost, avoid sending unsubscription messages
261                         udpSenderActive = false;
262                         disconnect();
263                         scheduleConnectRetry(retryConnectInMinutes);
264                     }
265                 }
266             }
267         } else if (ThingStatus.OFFLINE.equals(getThing().getStatusInfo().getStatus())) {
268             logger.debug("Keep alive pool job, '{}' is '{}'", getThing().getThingTypeUID(),
269                     getThing().getStatusInfo().getStatus());
270         }
271     }
272
273     private void handleStatusUpdate(EmotivaUdpResponse emotivaUdpResponse) {
274         udpSenderActive = true;
275         logger.debug("Received data from '{}' with length '{}'", emotivaUdpResponse.ipAddress(),
276                 emotivaUdpResponse.answer().length());
277
278         Object object;
279         try {
280             object = xmlUtils.unmarshallToEmotivaDTO(emotivaUdpResponse.answer());
281         } catch (JAXBException e) {
282             logger.debug("Could not unmarshal answer from '{}' with length '{}' and content '{}'",
283                     emotivaUdpResponse.ipAddress(), emotivaUdpResponse.answer().length(), emotivaUdpResponse.answer(),
284                     e);
285             return;
286         }
287
288         if (object instanceof EmotivaAckDTO) {
289             // Currently not supported to revert a failed command update, just used for logging for now.
290             logger.trace("Processing received '{}' with '{}'", EmotivaAckDTO.class.getSimpleName(),
291                     emotivaUdpResponse.answer());
292         } else if (object instanceof EmotivaBarNotifyWrapper answerDto) {
293             logger.trace("Processing received '{}' with '{}'", EmotivaBarNotifyWrapper.class.getSimpleName(),
294                     emotivaUdpResponse.answer());
295
296             List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
297
298             if (!emotivaBarNotifies.isEmpty()) {
299                 if (emotivaBarNotifies.get(0).getType() != null) {
300                     findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
301                             STRING);
302                 }
303             }
304         } else if (object instanceof EmotivaNotifyWrapper answerDto) {
305             logger.trace("Processing received '{}' with '{}'", EmotivaNotifyWrapper.class.getSimpleName(),
306                     emotivaUdpResponse.answer());
307             handleNotificationUpdate(answerDto);
308         } else if (object instanceof EmotivaUpdateResponse answerDto) {
309             logger.trace("Processing received '{}' with '{}'", EmotivaUpdateResponse.class.getSimpleName(),
310                     emotivaUdpResponse.answer());
311             handleNotificationUpdate(answerDto);
312         } else if (object instanceof EmotivaMenuNotifyDTO answerDto) {
313             logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
314                     emotivaUdpResponse.answer());
315
316             if (answerDto.getRow() != null) {
317                 handleMenuNotify(answerDto);
318             } else if (answerDto.getProgress() != null && answerDto.getProgress().getTime() != null) {
319                 logger.trace("Processing received '{}' with '{}'", EmotivaMenuNotifyDTO.class.getSimpleName(),
320                         emotivaUdpResponse.answer());
321                 listeningThreadFactory
322                         .newThread(() -> handleMenuNotifyProgressMessage(answerDto.getProgress().getTime())).start();
323             }
324         } else if (object instanceof EmotivaSubscriptionResponse answerDto) {
325             logger.trace("Processing received '{}' with '{}'", EmotivaSubscriptionResponse.class.getSimpleName(),
326                     emotivaUdpResponse.answer());
327             // Populates static input sources, except input
328             EnumMap<EmotivaControlCommands, String> sourceMainZone = EmotivaControlCommands
329                     .getCommandsFromType(EmotivaCommandType.SOURCE_MAIN_ZONE);
330             sourceMainZone.remove(EmotivaControlCommands.input);
331             state.setSourcesMainZone(sourceMainZone);
332
333             EnumMap<EmotivaControlCommands, String> sourcesZone2 = EmotivaControlCommands
334                     .getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2);
335             sourcesZone2.remove(EmotivaControlCommands.input);
336             state.setSourcesZone2(sourcesZone2);
337
338             if (answerDto.getProperties() == null) {
339                 for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
340                     handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
341                 }
342             } else {
343                 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
344                     handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
345                             property.getStatus());
346                 }
347             }
348         }
349     }
350
351     private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
352         String highlightValue = "";
353
354         for (var row = 4; row <= 6; row++) {
355             var emotivaMenuRow = answerDto.getRow().get(row);
356             logger.debug("Checking row '{}' with '{}' columns", row, emotivaMenuRow.getCol().size());
357             for (var column = 0; column <= 2; column++) {
358                 var emotivaMenuCol = emotivaMenuRow.getCol().get(column);
359                 String cellValue = "";
360                 if (emotivaMenuCol.getValue() != null) {
361                     cellValue = emotivaMenuCol.getValue();
362                 }
363
364                 if (emotivaMenuCol.getCheckbox() != null) {
365                     cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
366                             : "☐";
367                 }
368
369                 if (emotivaMenuCol.getHighlight() != null
370                         && MENU_PANEL_HIGHLIGHTED.equalsIgnoreCase(emotivaMenuCol.getHighlight().trim())) {
371                     logger.debug("Highlight is at row '{}' column '{}' value '{}'", row, column, cellValue);
372                     highlightValue = cellValue;
373                 }
374
375                 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
376                         getMenuPanelColumnLabel(column));
377                 updateChannelState(channelName, new StringType(cellValue));
378             }
379         }
380         updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
381     }
382
383     private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
384         try {
385             var seconds = Integer.parseInt(progressBarTimeInSeconds);
386             for (var count = 0; seconds >= count; count++) {
387                 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT,
388                         new StringType(updateProgress(EmotivaCommandHelper.integerToPercentage(count))));
389             }
390         } catch (NumberFormatException e) {
391             logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
392         }
393     }
394
395     private void resetMenuPanelChannels() {
396         logger.debug("Resetting Menu Panel Display");
397         for (var row = 4; row <= 6; row++) {
398             for (var column = 0; column <= 2; column++) {
399                 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
400                         getMenuPanelColumnLabel(column));
401                 updateChannelState(channelName, new StringType(""));
402             }
403         }
404         updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
405     }
406
407     private void sendEmotivaUpdate(EmotivaControlCommands tags) {
408         EmotivaUdpSendingService localSendingService = sendingService;
409         if (localSendingService != null) {
410             try {
411                 localSendingService.sendUpdate(tags, config);
412             } catch (InterruptedIOException e) {
413                 logger.debug("Interrupted during sending of EmotivaUpdate message to device '{}'",
414                         this.getThing().getThingTypeUID(), e);
415             } catch (IOException e) {
416                 logger.error("Failed to send EmotivaUpdate message to device '{}'", this.getThing().getThingTypeUID(),
417                         e);
418             }
419         }
420     }
421
422     private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
423         if (answerDto.getProperties() == null) {
424             for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
425                 try {
426                     EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
427                     if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
428                         findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
429                                 tagName.getDataType());
430                     }
431                 } catch (IllegalArgumentException e) {
432                     logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
433                 }
434             }
435         } else {
436             for (EmotivaPropertyDTO property : answerDto.getProperties()) {
437                 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
438                         property.getStatus());
439             }
440         }
441     }
442
443     private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
444         logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
445
446         if (status.equals(NOT_VALID.name())) {
447             logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
448             return;
449         }
450
451         if ("None".equals(value)) {
452             logger.debug("No value present for channel {}, usually means a speaker is not enabled",
453                     emotivaSubscriptionName);
454             return;
455         }
456
457         try {
458             EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
459         } catch (IllegalArgumentException e) {
460             logger.debug("Subscription property '{}' is not know to the binding, might need updating",
461                     emotivaSubscriptionName);
462             return;
463         }
464
465         if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
466             logger.debug("Initial subscription status update for {}, skipping, only want notifications",
467                     emotivaSubscriptionName);
468             return;
469         }
470
471         try {
472             EmotivaSubscriptionTags subscriptionTag;
473             try {
474                 subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
475             } catch (IllegalArgumentException e) {
476                 logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
477                 return;
478             }
479
480             if (subscriptionTag.getChannel().isEmpty()) {
481                 logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
482                         emotivaSubscriptionName);
483                 return;
484             }
485
486             String trimmedValue = value.trim();
487
488             logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
489                     trimmedValue);
490
491             // Add/Update user assigned name for inputs
492             if (subscriptionTag.getChannel().startsWith(CHANNEL_INPUT1.substring(0, CHANNEL_INPUT1.indexOf("-") + 1))
493                     && "true".equals(visible)) {
494                 state.updateSourcesMainZone(EmotivaControlCommands.matchToInput(subscriptionTag.name()), trimmedValue);
495                 logger.debug("Adding '{}' to dynamic source input list, map is now {}", trimmedValue,
496                         state.getSourcesMainZone());
497             }
498
499             // Add/Update audio modes
500             if (subscriptionTag.getChannel().startsWith(CHANNEL_MODE + "-") && "true".equals(visible)) {
501                 String modeName = i18nProvider.getText("channel-type.emotiva.selected-mode.option."
502                         + subscriptionTag.getChannel().substring(subscriptionTag.getChannel().indexOf("_") + 1));
503                 logger.debug("Adding '{} ({})' from channel '{}' to dynamic mode list", trimmedValue, modeName,
504                         subscriptionTag.getChannel());
505                 state.updateModes(EmotivaSubscriptionTags.fromChannelUID(subscriptionTag.getChannel()), trimmedValue);
506             }
507
508             findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
509                     subscriptionTag.getDataType());
510         } catch (IllegalArgumentException e) {
511             logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
512         }
513     }
514
515     private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
516         switch (dataType) {
517             case DIMENSIONLESS_DECIBEL -> {
518                 var trimmedString = value.replaceAll("[ +]", "");
519                 logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
520                         trimmedString);
521                 if (channelName.equals(CHANNEL_MAIN_VOLUME)) {
522                     updateVolumeChannels(trimmedString, CHANNEL_MUTE, channelName, CHANNEL_MAIN_VOLUME_DB);
523                 } else if (channelName.equals(CHANNEL_ZONE2_VOLUME)) {
524                     updateVolumeChannels(trimmedString, CHANNEL_ZONE2_MUTE, channelName, CHANNEL_ZONE2_VOLUME_DB);
525                 } else {
526                     if (trimmedString.equals("None")) {
527                         updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
528                     } else {
529                         updateChannelState(channelName,
530                                 QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
531                     }
532                 }
533             }
534             case DIMENSIONLESS_PERCENT -> {
535                 var trimmedString = value.replaceAll("[ +]", "");
536                 logger.debug("Update channel '{}' to '{}:{}'", channelName, PercentType.class.getSimpleName(), value);
537                 updateChannelState(channelName, PercentType.valueOf(trimmedString));
538             }
539             case FREQUENCY_HERTZ -> {
540                 logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
541                         value);
542                 if (!value.isEmpty()) {
543                     // Getting rid of characters and empty space leaves us with the raw frequency
544                     try {
545                         String frequencyString = value.replaceAll("[a-zA-Z ]", "");
546                         QuantityType<Frequency> hz = QuantityType.valueOf(0, Units.HERTZ);
547                         if (value.contains("AM")) {
548                             hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000, Units.HERTZ);
549                         } else if (value.contains("FM")) {
550                             hz = QuantityType.valueOf(Double.parseDouble(frequencyString) * 1000000, Units.HERTZ);
551                         }
552                         updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
553                     } catch (NumberFormatException e) {
554                         logger.debug("Could not extract radio tuner frequency from '{}'", value);
555                     }
556                 }
557             }
558             case GOODBYE -> {
559                 logger.info(
560                         "Received goodbye notification from '{}'; disconnecting and scheduling av connection retry in '{}' minutes",
561                         getThing().getUID(), DEFAULT_RETRY_INTERVAL_MINUTES);
562                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "@text/message.processor.goodbye");
563
564                 // Device gone, sending unsubscription messages not needed
565                 udpSenderActive = false;
566                 disconnect();
567                 scheduleConnectRetry(retryConnectInMinutes);
568             }
569             case NUMBER_TIME -> {
570                 logger.debug("Update channel '{}' to '{}:{}'", channelName, Number.class.getSimpleName(), value);
571                 long nowEpochSecond = Instant.now().getEpochSecond();
572                 updateChannelState(channelName, new QuantityType<>(nowEpochSecond, Units.SECOND));
573             }
574             case ON_OFF -> {
575                 logger.debug("Update channel '{}' to '{}:{}'", channelName, OnOffType.class.getSimpleName(), value);
576                 OnOffType switchValue = OnOffType.from(value.trim().toUpperCase());
577                 updateChannelState(channelName, switchValue);
578                 if (switchValue.equals(OnOffType.OFF) && CHANNEL_MENU.equals(channelName)) {
579                     resetMenuPanelChannels();
580                 }
581             }
582             case STRING -> {
583                 logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
584                 updateChannelState(channelName, StringType.valueOf(value));
585             }
586             case UNKNOWN -> // Do nothing, types not connect to channels
587                 logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
588             default -> {
589                 // datatypes not connect to a channel, so do nothing
590             }
591         }
592     }
593
594     private void updateChannelState(String channelID, State channelState) {
595         state.updateChannel(channelID, channelState);
596         logger.trace("Updating channel '{}' with '{}'", channelID, channelState);
597         updateState(channelID, channelState);
598     }
599
600     private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
601         if ("Mute".equals(value)) {
602             updateChannelState(muteChannel, OnOffType.ON);
603         } else {
604             updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
605             updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
606         }
607     }
608
609     @Override
610     public void handleCommand(ChannelUID channelUID, Command ohCommand) {
611         logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
612         EmotivaUdpSendingService localSendingService = sendingService;
613
614         if (localSendingService != null) {
615             EmotivaControlRequest emotivaRequest = channelToControlRequest(channelUID.getId(), state,
616                     protocolFromConfig(config.protocolVersion));
617             if (ohCommand instanceof RefreshType) {
618                 state.removeChannel(channelUID.getId());
619
620                 if (emotivaRequest.getDefaultCommand().equals(none)) {
621                     logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
622                             emotivaRequest.getName(), channelUID);
623                 } else {
624                     logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
625                     sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
626                 }
627             } else {
628                 try {
629                     Optional<State> channel = state.getChannel(channelUID.getId());
630                     if (channel.isPresent()) {
631                         EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, channel.get());
632                         localSendingService.send(dto);
633
634                         if (emotivaRequest.getName().equals(EmotivaControlCommands.volume.name())) {
635                             if (ohCommand instanceof PercentType value) {
636                                 updateChannelState(CHANNEL_MAIN_VOLUME_DB, QuantityType
637                                         .valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
638                             } else if (ohCommand instanceof QuantityType<?> value) {
639                                 updateChannelState(CHANNEL_MAIN_VOLUME, volumeDecibelToPercentage(value.toString()));
640                             }
641                         } else if (emotivaRequest.getName().equals(EmotivaControlCommands.zone2_volume.name())) {
642                             if (ohCommand instanceof PercentType value) {
643                                 updateChannelState(CHANNEL_ZONE2_VOLUME_DB, QuantityType
644                                         .valueOf(volumePercentageToDecibel(value.intValue()), Units.DECIBEL));
645                             } else if (ohCommand instanceof QuantityType<?> value) {
646                                 updateChannelState(CHANNEL_ZONE2_VOLUME, volumeDecibelToPercentage(value.toString()));
647                             }
648                         } else if (ohCommand instanceof OnOffType value) {
649                             if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
650                                 localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
651                             }
652                         }
653                     }
654                 } catch (InterruptedIOException e) {
655                     logger.debug("Interrupted during updating state for channel: '{}:{}:{}'", channelUID.getId(),
656                             emotivaRequest.getName(), emotivaRequest.getDataType(), e);
657                 } catch (IOException e) {
658                     logger.error("Failed updating state for channel '{}:{}:{}'", channelUID.getId(),
659                             emotivaRequest.getName(), emotivaRequest.getDataType(), e);
660                 }
661             }
662         }
663     }
664
665     @Override
666     public void dispose() {
667         logger.debug("Disposing '{}'", getThing().getUID());
668
669         disconnect();
670         super.dispose();
671     }
672
673     private synchronized void disconnect() {
674         final EmotivaUdpSendingService localSendingService = sendingService;
675         if (localSendingService != null) {
676             logger.debug("Disposing active sender");
677             if (udpSenderActive) {
678                 try {
679                     // Unsubscribe before disconnect
680                     localSendingService.sendUnsubscribe(generalSubscription);
681                     localSendingService.sendUnsubscribe(mainZoneSubscriptions);
682                     localSendingService.sendUnsubscribe(zone2Subscriptions);
683                 } catch (IOException e) {
684                     logger.debug("Failed to unsubscribe for '{}'", config.ipAddress, e);
685                 }
686             }
687
688             sendingService = null;
689             try {
690                 localSendingService.disconnect();
691                 logger.debug("Disconnected udp send connector");
692             } catch (Exception e) {
693                 logger.debug("Failed to close socket connection for '{}'", config.ipAddress, e);
694             }
695         }
696         udpSenderActive = false;
697
698         final EmotivaUdpReceivingService notifyConnector = notifyListener;
699         if (notifyConnector != null) {
700             notifyListener = null;
701             try {
702                 notifyConnector.disconnect();
703                 logger.debug("Disconnected notify connector");
704             } catch (Exception e) {
705                 logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
706             }
707         }
708
709         final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
710         if (menuConnector != null) {
711             menuNotifyListener = null;
712             try {
713                 menuConnector.disconnect();
714                 logger.debug("Disconnected menu notify connector");
715             } catch (Exception e) {
716                 logger.error("Failed to close socket connection for: '{}:{}'", config.ipAddress, config.notifyPort, e);
717             }
718         }
719
720         ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
721         if (localConnectRetryJob != null) {
722             localConnectRetryJob.cancel(true);
723             this.connectRetryJob = null;
724         }
725
726         ScheduledFuture<?> localPollingJob = this.pollingJob;
727         if (localPollingJob != null) {
728             localPollingJob.cancel(true);
729             this.pollingJob = null;
730             logger.debug("Polling job canceled");
731         }
732     }
733
734     @Override
735     public Collection<Class<? extends ThingHandlerService>> getServices() {
736         return Set.of(InputStateOptionProvider.class);
737     }
738
739     public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
740         return state.getSourcesMainZone();
741     }
742
743     public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
744         return state.getSourcesZone2();
745     }
746
747     public EnumMap<EmotivaSubscriptionTags, String> getModes() {
748         return state.getModes();
749     }
750 }