]> git.basschouten.com Git - openhab-addons.git/blob
9b1d07becf04db537d812b348beb6aef761ba7ee
[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.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;
34
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;
47 import java.util.Map;
48 import java.util.Set;
49 import java.util.concurrent.ConcurrentHashMap;
50 import java.util.concurrent.ScheduledFuture;
51 import java.util.concurrent.TimeUnit;
52
53 import javax.measure.quantity.Frequency;
54 import javax.xml.bind.JAXBException;
55
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;
93
94 /**
95  * The EmotivaProcessorHandler is responsible for handling OpenHAB commands, which are
96  * sent to one of the channels.
97  *
98  * @author Espen Fossen - Initial contribution
99  */
100 @NonNullByDefault
101 public class EmotivaProcessorHandler extends BaseThingHandler {
102
103     private final Logger logger = LoggerFactory.getLogger(EmotivaProcessorHandler.class);
104
105     private final Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
106
107     private final EmotivaConfiguration config;
108
109     /**
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.
112      */
113     private final EmotivaSubscriptionTags[] generalSubscription = EmotivaSubscriptionTags.generalChannels();
114     private final EmotivaSubscriptionTags[] nonGeneralSubscriptions = EmotivaSubscriptionTags.nonGeneralChannels();
115
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;
121
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;
127
128     private final int retryConnectInMinutes;
129
130     /**
131      * Thread factory for menu progress bar
132      */
133     private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(BINDING_ID, true);
134
135     private final EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
136
137     private boolean udpSenderActive = false;
138
139     public EmotivaProcessorHandler(Thing thing, EmotivaTranslationProvider i18nProvider) throws JAXBException {
140         super(thing);
141         this.i18nProvider = i18nProvider;
142         this.config = getConfigAs(EmotivaConfiguration.class);
143         this.retryConnectInMinutes = config.retryConnectInMinutes;
144
145         sourcesMainZone = new EnumMap<>(EmotivaControlCommands.class);
146         commandMaps.put(MAP_SOURCES_MAIN_ZONE, sourcesMainZone);
147
148         sourcesZone2 = new EnumMap<>(EmotivaControlCommands.class);
149         commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
150
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);
173
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);
177
178         modes = new EnumMap<>(EmotivaSubscriptionTags.class);
179     }
180
181     @Override
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");
188             return;
189         }
190         if (config.ipAddress.trim().isEmpty()) {
191             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
192                     "@text/message.processor.connection.error.address-empty");
193             return;
194         } else {
195             try {
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");
201                 return;
202             }
203         }
204
205         scheduler.execute(this::connect);
206     }
207
208     private synchronized void connect() {
209         final EmotivaConfiguration localConfig = config;
210         try {
211             final EmotivaUdpReceivingService notifyListener = new EmotivaUdpReceivingService(localConfig.notifyPort,
212                     localConfig, scheduler);
213             this.notifyListener = notifyListener;
214             notifyListener.connect(this::handleStatusUpdate, true);
215
216             final EmotivaUdpSendingService sendConnector = new EmotivaUdpSendingService(localConfig, scheduler);
217             sendingService = sendConnector;
218             sendConnector.connect(this::handleStatusUpdate, true);
219
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++) {
222                 try {
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
228                 }
229
230                 for (int delay = 0; delay < 10 && !udpSenderActive; delay++) {
231                     Thread.sleep(200); // wait 10 x 200ms = 2sec
232                 }
233             }
234
235             if (udpSenderActive) {
236                 updateStatus(ThingStatus.ONLINE);
237
238                 final EmotivaUdpReceivingService menuListenerConnector = new EmotivaUdpReceivingService(
239                         localConfig.menuNotifyPort, localConfig, scheduler);
240                 this.menuNotifyListener = menuListenerConnector;
241                 menuListenerConnector.connect(this::handleStatusUpdate, true);
242
243                 startPollingKeepAlive();
244             } else {
245                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
246                         "@text/message.processor.connection.failed");
247                 disconnect();
248                 scheduleConnectRetry(retryConnectInMinutes);
249             }
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");
256             disconnect();
257             scheduleConnectRetry(retryConnectInMinutes);
258         }
259     }
260
261     private void scheduleConnectRetry(long waitMinutes) {
262         logger.debug("Scheduling connection retry in '{}' minutes", waitMinutes);
263         connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
264     }
265
266     /**
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.
270      */
271     private void startPollingKeepAlive() {
272         final ScheduledFuture<?> localRefreshJob = this.pollingJob;
273         if (localRefreshJob == null || localRefreshJob.isCancelled()) {
274             logger.debug("Start polling");
275
276             int delay = stateMap.get(EmotivaSubscriptionTags.keepAlive.name()) != null
277                     && stateMap.get(EmotivaSubscriptionTags.keepAlive.name()) instanceof Number keepAlive
278                             ? keepAlive.intValue()
279                             : config.keepAlive;
280             pollingJob = scheduler.scheduleWithFixedDelay(this::checkKeepAliveTimestamp,
281                     delay + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS, delay + DEFAULT_KEEP_ALIVE_IN_MILLISECONDS,
282                     TimeUnit.MILLISECONDS);
283         }
284     }
285
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)) {
294                     logger.debug(
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;
303                     disconnect();
304                     scheduleConnectRetry(retryConnectInMinutes);
305                 }
306             }
307         } else if (ThingStatus.OFFLINE.equals(getThing().getStatusInfo().getStatus())) {
308             logger.debug("Keep alive pool job, '{}' is '{}'", getThing().getThingTypeUID(),
309                     getThing().getStatusInfo().getStatus());
310         }
311     }
312
313     private void handleStatusUpdate(EmotivaUdpResponse emotivaUdpResponse) {
314         udpSenderActive = true;
315         logger.debug("Received data from '{}' with length '{}'", emotivaUdpResponse.ipAddress(),
316                 emotivaUdpResponse.answer().length());
317
318         Object object;
319         try {
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(),
324                     e);
325             return;
326         }
327
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());
334
335             List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
336
337             if (!emotivaBarNotifies.isEmpty()) {
338                 if (emotivaBarNotifies.get(0).getType() != null) {
339                     findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
340                             STRING);
341                 }
342             }
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());
354
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();
362             }
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);
370
371             sourcesZone2.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2));
372             sourcesZone2.remove(EmotivaControlCommands.zone2_input);
373             commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
374
375             if (answerDto.getProperties() == null) {
376                 for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
377                     handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
378                 }
379             } else {
380                 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
381                     handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
382                             property.getStatus());
383                 }
384             }
385         }
386     }
387
388     private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
389         String highlightValue = "";
390
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();
399                 }
400
401                 if (emotivaMenuCol.getCheckbox() != null) {
402                     cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
403                             : "☐";
404                 }
405
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;
410                 }
411
412                 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
413                         getMenuPanelColumnLabel(column));
414                 updateChannelState(channelName, new StringType(cellValue));
415             }
416         }
417         updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
418     }
419
420     private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
421         try {
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))));
426             }
427         } catch (NumberFormatException e) {
428             logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
429         }
430     }
431
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(""));
439             }
440         }
441         updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
442     }
443
444     private void sendEmotivaUpdate(EmotivaControlCommands tags) {
445         EmotivaUdpSendingService localSendingService = sendingService;
446         if (localSendingService != null) {
447             try {
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(),
454                         e);
455             }
456         }
457     }
458
459     private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
460         if (answerDto.getProperties() == null) {
461             for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
462                 try {
463                     EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
464                     if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
465                         findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
466                                 tagName.getDataType());
467                     }
468                 } catch (IllegalArgumentException e) {
469                     logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
470                 }
471             }
472         } else {
473             for (EmotivaPropertyDTO property : answerDto.getProperties()) {
474                 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
475                         property.getStatus());
476             }
477         }
478     }
479
480     private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
481         logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
482
483         if (status.equals(NOT_VALID.name())) {
484             logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
485             return;
486         }
487
488         if ("None".equals(value)) {
489             logger.debug("No value present for channel {}, usually means a speaker is not enabled",
490                     emotivaSubscriptionName);
491             return;
492         }
493
494         try {
495             EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
496         } catch (IllegalArgumentException e) {
497             logger.debug("Subscription property '{}' is not know to the binding, might need updating",
498                     emotivaSubscriptionName);
499             return;
500         }
501
502         if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
503             logger.debug("Initial subscription status update for {}, skipping, only want notifications",
504                     emotivaSubscriptionName);
505             return;
506         }
507
508         try {
509             EmotivaSubscriptionTags subscriptionTag;
510             try {
511                 subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
512             } catch (IllegalArgumentException e) {
513                 logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
514                 return;
515             }
516
517             if (subscriptionTag.getChannel().isEmpty()) {
518                 logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
519                         emotivaSubscriptionName);
520                 return;
521             }
522
523             String trimmedValue = value.trim();
524
525             logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
526                     trimmedValue);
527
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);
534
535                 logger.debug("sources list is now {}", sourcesMainZone.size());
536             }
537
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);
545             }
546
547             findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
548                     subscriptionTag.getDataType());
549         } catch (IllegalArgumentException e) {
550             logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
551         }
552     }
553
554     private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
555         switch (dataType) {
556             case DIMENSIONLESS_DECIBEL -> {
557                 var trimmedString = value.replaceAll("[ +]", "");
558                 logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
559                         trimmedString);
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);
564                 } else {
565                     if (trimmedString.equals("None")) {
566                         updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
567                     } else {
568                         updateChannelState(channelName,
569                                 QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
570                     }
571                 }
572             }
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));
577             }
578             case FREQUENCY_HERTZ -> {
579                 logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
580                         value);
581                 if (!value.isEmpty()) {
582                     // Getting rid of characters and empty space leaves us with the raw frequency
583                     try {
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);
590                         }
591                         updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
592                     } catch (NumberFormatException e) {
593                         logger.debug("Could not extract radio tuner frequency from '{}'", value);
594                     }
595                 }
596             }
597             case GOODBYE -> {
598                 logger.info(
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");
602
603                 // Device gone, sending unsubscription messages not needed
604                 udpSenderActive = false;
605                 disconnect();
606                 scheduleConnectRetry(retryConnectInMinutes);
607             }
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));
612             }
613             case ON_OFF -> {
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();
619                 }
620             }
621             case STRING -> {
622                 logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
623                 updateChannelState(channelName, StringType.valueOf(value));
624             }
625             case UNKNOWN -> // Do nothing, types not connect to channels
626                 logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
627             default -> {
628                 // datatypes not connect to a channel, so do nothing
629             }
630         }
631     }
632
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);
637     }
638
639     private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
640         if ("Mute".equals(value)) {
641             updateChannelState(muteChannel, OnOffType.ON);
642         } else {
643             updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
644             updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
645         }
646     }
647
648     @Override
649     public void handleCommand(ChannelUID channelUID, Command ohCommand) {
650         logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
651         EmotivaUdpSendingService localSendingService = sendingService;
652
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());
658
659                 if (emotivaRequest.getDefaultCommand().equals(none)) {
660                     logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
661                             emotivaRequest.getName(), channelUID);
662                 } else {
663                     logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
664                     sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
665                 }
666             } else {
667                 try {
668                     EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, stateMap.get(channelUID.getId()));
669                     localSendingService.send(dto);
670
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()));
677                         }
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()));
684                         }
685                     } else if (ohCommand instanceof OnOffType value) {
686                         if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
687                             localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
688                         }
689                     }
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);
696                 }
697             }
698         }
699     }
700
701     @Override
702     public void dispose() {
703         logger.debug("Disposing '{}'", getThing().getUID());
704
705         disconnect();
706         super.dispose();
707     }
708
709     private synchronized void disconnect() {
710         final EmotivaUdpSendingService localSendingService = sendingService;
711         if (localSendingService != null) {
712             logger.debug("Disposing active sender");
713             if (udpSenderActive) {
714                 try {
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);
720                 }
721             }
722
723             sendingService = null;
724             try {
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);
729             }
730         }
731         udpSenderActive = false;
732
733         final EmotivaUdpReceivingService notifyConnector = notifyListener;
734         if (notifyConnector != null) {
735             notifyListener = null;
736             try {
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);
741             }
742         }
743
744         final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
745         if (menuConnector != null) {
746             menuNotifyListener = null;
747             try {
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);
752             }
753         }
754
755         ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
756         if (localConnectRetryJob != null) {
757             localConnectRetryJob.cancel(true);
758             this.connectRetryJob = null;
759         }
760
761         ScheduledFuture<?> localPollingJob = this.pollingJob;
762         if (localPollingJob != null) {
763             localPollingJob.cancel(true);
764             this.pollingJob = null;
765             logger.debug("Polling job canceled");
766         }
767     }
768
769     @Override
770     public Collection<Class<? extends ThingHandlerService>> getServices() {
771         return Set.of(InputStateOptionProvider.class);
772     }
773
774     public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
775         return sourcesMainZone;
776     }
777
778     public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
779         return sourcesZone2;
780     }
781
782     public EnumMap<EmotivaSubscriptionTags, String> getModes() {
783         return modes;
784     }
785 }