2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.emotiva.internal;
15 import static java.lang.String.format;
16 import static org.openhab.binding.emotiva.internal.EmotivaBindingConstants.*;
17 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.channelToControlRequest;
18 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.getMenuPanelColumnLabel;
19 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.getMenuPanelRowLabel;
20 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.updateProgress;
21 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumeDecibelToPercentage;
22 import static org.openhab.binding.emotiva.internal.EmotivaCommandHelper.volumePercentageToDecibel;
23 import static org.openhab.binding.emotiva.internal.protocol.EmotivaControlCommands.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;
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;
42 import java.util.concurrent.ScheduledFuture;
43 import java.util.concurrent.TimeUnit;
45 import javax.measure.quantity.Frequency;
46 import javax.xml.bind.JAXBException;
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;
88 * The EmotivaProcessorHandler is responsible for handling OpenHAB commands, which are
89 * sent to one of the channels.
91 * @author Espen Fossen - Initial contribution
94 public class EmotivaProcessorHandler extends BaseThingHandler {
96 private final Logger logger = LoggerFactory.getLogger(EmotivaProcessorHandler.class);
98 private final EmotivaConfiguration config;
101 * Emotiva devices have trouble with too many subscriptions in same request, so subscriptions are dividing into
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");
108 private final EmotivaProcessorState state = new EmotivaProcessorState();
109 private final EmotivaTranslationProvider i18nProvider;
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;
117 private final int retryConnectInMinutes;
120 * Thread factory for menu progress bar
122 private final NamedThreadFactory listeningThreadFactory = new NamedThreadFactory(BINDING_ID, true);
124 private final EmotivaXmlUtils xmlUtils = new EmotivaXmlUtils();
126 private boolean udpSenderActive = false;
128 public EmotivaProcessorHandler(Thing thing, EmotivaTranslationProvider i18nProvider) throws JAXBException {
130 this.i18nProvider = i18nProvider;
131 this.config = getConfigAs(EmotivaConfiguration.class);
132 this.retryConnectInMinutes = config.retryConnectInMinutes;
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");
144 if (config.ipAddress.trim().isEmpty()) {
145 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
146 "@text/message.processor.connection.error.address-empty");
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");
159 scheduler.execute(this::connect);
162 private synchronized void connect() {
163 final EmotivaConfiguration localConfig = config;
165 final EmotivaUdpReceivingService notifyListener = new EmotivaUdpReceivingService(localConfig.notifyPort,
166 localConfig, scheduler);
167 this.notifyListener = notifyListener;
168 notifyListener.connect(this::handleStatusUpdate, true);
170 final EmotivaUdpSendingService sendConnector = new EmotivaUdpSendingService(localConfig, scheduler);
171 sendingService = sendConnector;
172 sendConnector.connect(this::handleStatusUpdate, true);
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++) {
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
185 for (int delay = 0; delay < 10 && !udpSenderActive; delay++) {
186 Thread.sleep(200); // wait 10 x 200ms = 2sec
190 if (udpSenderActive) {
191 updateStatus(ThingStatus.ONLINE);
193 final EmotivaUdpReceivingService menuListenerConnector = new EmotivaUdpReceivingService(
194 localConfig.menuNotifyPort, localConfig, scheduler);
195 this.menuNotifyListener = menuListenerConnector;
196 menuListenerConnector.connect(this::handleStatusUpdate, true);
198 startPollingKeepAlive();
200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR,
201 "@text/message.processor.connection.failed");
203 scheduleConnectRetry(retryConnectInMinutes);
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");
212 scheduleConnectRetry(retryConnectInMinutes);
216 private void scheduleConnectRetry(long waitMinutes) {
217 logger.debug("Scheduling connection retry in '{}' minutes", waitMinutes);
218 connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
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.
226 private void startPollingKeepAlive() {
227 final ScheduledFuture<?> localRefreshJob = this.pollingJob;
228 if (localRefreshJob == null || localRefreshJob.isCancelled()) {
230 Number keepAliveConfig = state.getChannel(EmotivaSubscriptionTags.keepAlive)
231 .filter(channel -> channel instanceof Number).map(keepAlive -> (Number) keepAlive)
232 .orElse(new DecimalType(config.keepAlive));
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);
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)) {
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;
263 scheduleConnectRetry(retryConnectInMinutes);
267 } else if (ThingStatus.OFFLINE.equals(getThing().getStatusInfo().getStatus())) {
268 logger.debug("Keep alive pool job, '{}' is '{}'", getThing().getThingTypeUID(),
269 getThing().getStatusInfo().getStatus());
273 private void handleStatusUpdate(EmotivaUdpResponse emotivaUdpResponse) {
274 udpSenderActive = true;
275 logger.debug("Received data from '{}' with length '{}'", emotivaUdpResponse.ipAddress(),
276 emotivaUdpResponse.answer().length());
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(),
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());
296 List<EmotivaBarNotifyDTO> emotivaBarNotifies = xmlUtils.unmarshallToBarNotify(answerDto.getTags());
298 if (!emotivaBarNotifies.isEmpty()) {
299 if (emotivaBarNotifies.get(0).getType() != null) {
300 findChannelDatatypeAndUpdateChannel(CHANNEL_BAR, emotivaBarNotifies.get(0).formattedMessage(),
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());
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();
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);
333 EnumMap<EmotivaControlCommands, String> sourcesZone2 = EmotivaControlCommands
334 .getCommandsFromType(EmotivaCommandType.SOURCE_ZONE2);
335 sourcesZone2.remove(EmotivaControlCommands.input);
336 state.setSourcesZone2(sourcesZone2);
338 if (answerDto.getProperties() == null) {
339 for (EmotivaNotifyDTO dto : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
340 handleChannelUpdate(dto.getName(), dto.getValue(), dto.getVisible(), dto.getAck());
343 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
344 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
345 property.getStatus());
351 private void handleMenuNotify(EmotivaMenuNotifyDTO answerDto) {
352 String highlightValue = "";
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();
364 if (emotivaMenuCol.getCheckbox() != null) {
365 cellValue = MENU_PANEL_CHECKBOX_ON.equalsIgnoreCase(emotivaMenuCol.getCheckbox().trim()) ? "☑"
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;
375 var channelName = format("%s-%s-%s", CHANNEL_MENU_DISPLAY_PREFIX, getMenuPanelRowLabel(row),
376 getMenuPanelColumnLabel(column));
377 updateChannelState(channelName, new StringType(cellValue));
380 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(highlightValue));
383 private void handleMenuNotifyProgressMessage(String progressBarTimeInSeconds) {
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))));
390 } catch (NumberFormatException e) {
391 logger.debug("Menu progress bar time value '{}' is not a valid integer", progressBarTimeInSeconds);
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(""));
404 updateChannelState(CHANNEL_MENU_DISPLAY_HIGHLIGHT, new StringType(""));
407 private void sendEmotivaUpdate(EmotivaControlCommands tags) {
408 EmotivaUdpSendingService localSendingService = sendingService;
409 if (localSendingService != null) {
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(),
422 private void handleNotificationUpdate(AbstractNotificationDTO answerDto) {
423 if (answerDto.getProperties() == null) {
424 for (EmotivaNotifyDTO tag : xmlUtils.unmarshallToNotification(answerDto.getTags())) {
426 EmotivaSubscriptionTags tagName = EmotivaSubscriptionTags.valueOf(tag.getName());
427 if (EmotivaSubscriptionTags.hasChannel(tag.getName())) {
428 findChannelDatatypeAndUpdateChannel(tagName.getChannel(), tag.getValue(),
429 tagName.getDataType());
431 } catch (IllegalArgumentException e) {
432 logger.debug("Subscription name '{}' could not be mapped to a channel", tag.getName());
436 for (EmotivaPropertyDTO property : answerDto.getProperties()) {
437 handleChannelUpdate(property.getName(), property.getValue(), property.getVisible(),
438 property.getStatus());
443 private void handleChannelUpdate(String emotivaSubscriptionName, String value, String visible, String status) {
444 logger.debug("Handling channel update for '{}' with value '{}'", emotivaSubscriptionName, value);
446 if (status.equals(NOT_VALID.name())) {
447 logger.debug("Subscription property '{}' not present in device, skipping", emotivaSubscriptionName);
451 if ("None".equals(value)) {
452 logger.debug("No value present for channel {}, usually means a speaker is not enabled",
453 emotivaSubscriptionName);
458 EmotivaSubscriptionTags.hasChannel(emotivaSubscriptionName);
459 } catch (IllegalArgumentException e) {
460 logger.debug("Subscription property '{}' is not know to the binding, might need updating",
461 emotivaSubscriptionName);
465 if (noSubscriptionToChannel().contains(EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName))) {
466 logger.debug("Initial subscription status update for {}, skipping, only want notifications",
467 emotivaSubscriptionName);
472 EmotivaSubscriptionTags subscriptionTag;
474 subscriptionTag = EmotivaSubscriptionTags.valueOf(emotivaSubscriptionName);
475 } catch (IllegalArgumentException e) {
476 logger.debug("Property '{}' could not be mapped subscription tag, skipping", emotivaSubscriptionName);
480 if (subscriptionTag.getChannel().isEmpty()) {
481 logger.debug("Subscription property '{}' does not have a corresponding channel, skipping",
482 emotivaSubscriptionName);
486 String trimmedValue = value.trim();
488 logger.debug("Found subscription '{}' for '{}' and value '{}'", subscriptionTag, emotivaSubscriptionName,
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());
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);
508 findChannelDatatypeAndUpdateChannel(subscriptionTag.getChannel(), trimmedValue,
509 subscriptionTag.getDataType());
510 } catch (IllegalArgumentException e) {
511 logger.debug("Error updating subscription property '{}'", emotivaSubscriptionName, e);
515 private void findChannelDatatypeAndUpdateChannel(String channelName, String value, EmotivaDataType dataType) {
517 case DIMENSIONLESS_DECIBEL -> {
518 var trimmedString = value.replaceAll("[ +]", "");
519 logger.debug("Update channel '{}' to '{}:{}'", channelName, QuantityType.class.getSimpleName(),
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);
526 if (trimmedString.equals("None")) {
527 updateChannelState(channelName, QuantityType.valueOf(0, Units.DECIBEL));
529 updateChannelState(channelName,
530 QuantityType.valueOf(Double.parseDouble(trimmedString), Units.DECIBEL));
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));
539 case FREQUENCY_HERTZ -> {
540 logger.debug("Update channel '{}' to '{}:{}'", channelName, Units.HERTZ.getClass().getSimpleName(),
542 if (!value.isEmpty()) {
543 // Getting rid of characters and empty space leaves us with the raw frequency
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);
552 updateChannelState(CHANNEL_TUNER_CHANNEL, hz);
553 } catch (NumberFormatException e) {
554 logger.debug("Could not extract radio tuner frequency from '{}'", value);
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");
564 // Device gone, sending unsubscription messages not needed
565 udpSenderActive = false;
567 scheduleConnectRetry(retryConnectInMinutes);
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));
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();
583 logger.debug("Update channel '{}' to '{}:{}'", channelName, StringType.class.getSimpleName(), value);
584 updateChannelState(channelName, StringType.valueOf(value));
586 case UNKNOWN -> // Do nothing, types not connect to channels
587 logger.debug("Channel '{}' with UNKNOWN type and value '{}' was not updated", channelName, value);
589 // datatypes not connect to a channel, so do nothing
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);
600 private void updateVolumeChannels(String value, String muteChannel, String volumeChannel, String volumeDbChannel) {
601 if ("Mute".equals(value)) {
602 updateChannelState(muteChannel, OnOffType.ON);
604 updateChannelState(volumeChannel, volumeDecibelToPercentage(value));
605 updateChannelState(volumeDbChannel, QuantityType.valueOf(Double.parseDouble(value), Units.DECIBEL));
610 public void handleCommand(ChannelUID channelUID, Command ohCommand) {
611 logger.debug("Handling ohCommand '{}:{}' for '{}'", channelUID.getId(), ohCommand, channelUID.getThingUID());
612 EmotivaUdpSendingService localSendingService = sendingService;
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());
620 if (emotivaRequest.getDefaultCommand().equals(none)) {
621 logger.debug("Found controlCommand 'none' for request '{}' from channel '{}' with RefreshType",
622 emotivaRequest.getName(), channelUID);
624 logger.debug("Sending EmotivaUpdate for '{}'", emotivaRequest);
625 sendEmotivaUpdate(emotivaRequest.getDefaultCommand());
629 Optional<State> channel = state.getChannel(channelUID.getId());
630 if (channel.isPresent()) {
631 EmotivaControlDTO dto = emotivaRequest.createDTO(ohCommand, channel.get());
632 localSendingService.send(dto);
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()));
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()));
648 } else if (ohCommand instanceof OnOffType value) {
649 if (value.equals(OnOffType.ON) && emotivaRequest.getOnCommand().equals(power_on)) {
650 localSendingService.sendUpdate(EmotivaSubscriptionTags.speakerChannels(), config);
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);
666 public void dispose() {
667 logger.debug("Disposing '{}'", getThing().getUID());
673 private synchronized void disconnect() {
674 final EmotivaUdpSendingService localSendingService = sendingService;
675 if (localSendingService != null) {
676 logger.debug("Disposing active sender");
677 if (udpSenderActive) {
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);
688 sendingService = null;
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);
696 udpSenderActive = false;
698 final EmotivaUdpReceivingService notifyConnector = notifyListener;
699 if (notifyConnector != null) {
700 notifyListener = null;
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);
709 final EmotivaUdpReceivingService menuConnector = menuNotifyListener;
710 if (menuConnector != null) {
711 menuNotifyListener = null;
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);
720 ScheduledFuture<?> localConnectRetryJob = this.connectRetryJob;
721 if (localConnectRetryJob != null) {
722 localConnectRetryJob.cancel(true);
723 this.connectRetryJob = null;
726 ScheduledFuture<?> localPollingJob = this.pollingJob;
727 if (localPollingJob != null) {
728 localPollingJob.cancel(true);
729 this.pollingJob = null;
730 logger.debug("Polling job canceled");
735 public Collection<Class<? extends ThingHandlerService>> getServices() {
736 return Set.of(InputStateOptionProvider.class);
739 public EnumMap<EmotivaControlCommands, String> getSourcesMainZone() {
740 return state.getSourcesMainZone();
743 public EnumMap<EmotivaControlCommands, String> getSourcesZone2() {
744 return state.getSourcesZone2();
747 public EnumMap<EmotivaSubscriptionTags, String> getModes() {
748 return state.getModes();