]> git.basschouten.com Git - openhab-addons.git/blob
9dceae4aecba3ef40118a921d65610a8ca0edcfc
[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
332         } else if (object instanceof EmotivaBarNotifyWrapper answerDto) {
333             logger.trace("Processing received '{}' with '{}'", EmotivaBarNotifyWrapper.class.getSimpleName(),
334                     emotivaUdpResponse.answer());
335
336             List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
337
338             if (!emotivaBarNotifies.isEmpty()) {
339                 if (emotivaBarNotifies.get(0).getType() != null) {
340                     findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
341                             STRING);
342                 }
343             }
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());
355
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();
363             }
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);
371
372             sourcesZone2.putAll(EmotivaControlCommands.getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2));
373             sourcesZone2.remove(EmotivaControlCommands.zone2_input);
374             commandMaps.put(MAP_SOURCES_ZONE_2, sourcesZone2);
375
376             if (answerDto.getProperties() == null) {
377                 for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
378                     handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
379                 }
380             } else {
381                 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
382                     handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
383                             property.getStatus());
384                 }
385             }
386         }
387     }
388
389     private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
390         String highlightValue = "";
391
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();
400                 }
401
402                 if (emotivaMenuCol.getCheckbox() != null) {
403                     cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
404                             : "☐";
405                 }
406
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;
411                 }
412
413                 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
414                         getMenuPanelColumnLabel(column));
415                 updateChannelState(channelName, new StringType(cellValue));
416             }
417         }
418         updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
419     }
420
421     private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
422         try {
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))));
427             }
428         } catch (NumberFormatException e) {
429             logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
430         }
431     }
432
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(""));
440             }
441         }
442         updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
443     }
444
445     private void sendEmotivaUpdate(EmotivaControlCommands tags) {
446         EmotivaUdpSendingService localSendingService = sendingService;
447         if (localSendingService != null) {
448             try {
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(),
455                         e);
456             }
457         }
458     }
459
460     private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
461         if (answerDto.getProperties() == null) {
462             for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
463                 try {
464                     EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
465                     if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
466                         findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
467                                 tagName.getDataType());
468                     }
469                 } catch (IllegalArgumentException e) {
470                     logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
471                 }
472             }
473         } else {
474             for (EmotivaPropertyDTO property : answerDto.getProperties()) {
475                 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
476                         property.getStatus());
477             }
478         }
479     }
480
481     private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
482         logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
483
484         if (status.equals(NOT_VALID.name())) {
485             logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
486             return;
487         }
488
489         if ("None".equals(value)) {
490             logger.debug("No value present for channel {}, usually means a speaker is not enabled",
491                     emotivaSubscriptionName);
492             return;
493         }
494
495         try {
496             EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
497         } catch (IllegalArgumentException e) {
498             logger.debug("Subscription property '{}' is not know to the binding, might need updating",
499                     emotivaSubscriptionName);
500             return;
501         }
502
503         if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
504             logger.debug("Initial subscription status update for {}, skipping, only want notifications",
505                     emotivaSubscriptionName);
506             return;
507         }
508
509         try {
510             EmotivaSubscriptionTags subscriptionTag;
511             try {
512                 subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
513             } catch (IllegalArgumentException e) {
514                 logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
515                 return;
516             }
517
518             if (subscriptionTag.getChannel().isEmpty()) {
519                 logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
520                         emotivaSubscriptionName);
521                 return;
522             }
523
524             String trimmedValue = value.trim();
525
526             logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
527                     trimmedValue);
528
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);
535
536                 logger.debug("sources list is now {}", sourcesMainZone.size());
537             }
538
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);
546             }
547
548             findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
549                     subscriptionTag.getDataType());
550         } catch (IllegalArgumentException e) {
551             logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
552         }
553     }
554
555     private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
556         switch (dataType) {
557             case DIMENSIONLESS_DECIBEL -> {
558                 var trimmedString = value.replaceAll("[ +]", "");
559                 logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
560                         trimmedString);
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);
565                 } else {
566                     if (trimmedString.equals("None")) {
567                         updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
568                     } else {
569                         updateChannelState(channelName,
570                                 QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
571                     }
572                 }
573             }
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));
578             }
579             case FREQUENCY_HERTZ -> {
580                 logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
581                         value);
582                 if (!value.isEmpty()) {
583                     // Getting rid of characters and empty space leaves us with the raw frequency
584                     try {
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);
591                         }
592                         updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
593                     } catch (NumberFormatException e) {
594                         logger.debug("Could not extract radio tuner frequency from '{}'", value);
595                     }
596                 }
597             }
598             case GOODBYE -> {
599                 logger.info(
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");
603
604                 // Device gone, sending unsubscription messages not needed
605                 udpSenderActive = false;
606                 disconnect();
607                 scheduleConnectRetry(retryConnectInMinutes);
608             }
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));
613             }
614             case ON_OFF -> {
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();
620                 }
621             }
622             case STRING -> {
623                 logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
624                 updateChannelState(channelName, StringType.valueOf(value));
625             }
626             case UNKNOWN -> // Do nothing, types not connect to channels
627                 logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
628             default -> {
629                 // datatypes not connect to a channel, so do nothing
630             }
631         }
632     }
633
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);
638     }
639
640     private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
641         if ("Mute".equals(value)) {
642             updateChannelState(muteChannel, OnOffType.ON);
643         } else {
644             updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
645             updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
646         }
647     }
648
649     @Override
650     public void handleCommand(ChannelUID channelUID, Command ohCommand) {
651         logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
652         EmotivaUdpSendingService localSendingService = sendingService;
653
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());
659
660                 if (emotivaRequest.getDefaultCommand().equals(none)) {
661                     logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
662                             emotivaRequest.getName(), channelUID);
663                 } else {
664                     logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
665                     sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
666                 }
667             } else {
668                 try {
669                     EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, stateMap.get(channelUID.getId()));
670                     localSendingService.send(dto);
671
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()));
678                         }
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()));
685                         }
686                     } else if (ohCommand instanceof OnOffType value) {
687                         if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
688                             localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
689                         }
690                     }
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);
697                 }
698             }
699         }
700     }
701
702     @Override
703     public void dispose() {
704         logger.debug("Disposing '{}'", getThing().getUID());
705
706         disconnect();
707         super.dispose();
708     }
709
710     private synchronized void disconnect() {
711         final EmotivaUdpSendingService localSendingService = sendingService;
712         if (localSendingService != null) {
713             logger.debug("Disposing active sender");
714             if (udpSenderActive) {
715                 try {
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);
721                 }
722             }
723
724             sendingService = null;
725             try {
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);
730             }
731         }
732         udpSenderActive = false;
733
734         final EmotivaUdpReceivingService notifyConnector = notifyListener;
735         if (notifyConnector != null) {
736             notifyListener = null;
737             try {
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);
742             }
743         }
744
745         final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
746         if (menuConnector != null) {
747             menuNotifyListener = null;
748             try {
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);
753             }
754         }
755
756         ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
757         if (localConnectRetryJob != null) {
758             localConnectRetryJob.cancel(true);
759             this.connectRetryJob = null;
760         }
761
762         ScheduledFuture<?> localPollingJob = this.pollingJob;
763         if (localPollingJob != null) {
764             localPollingJob.cancel(true);
765             this.pollingJob = null;
766             logger.debug("Polling job canceled");
767         }
768     }
769
770     @Override
771     public Collection<Class<? extends ThingHandlerService>> getServices() {
772         return Set.of(InputStateOptionProvider.class);
773     }
774
775     public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
776         return sourcesMainZone;
777     }
778
779     public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
780         return sourcesZone2;
781     }
782
783     public EnumMap<EmotivaSubscriptionTags, String> getModes() {
784         return modes;
785     }
786 }