]> git.basschouten.com Git - openhab-addons.git/blob
9e244d9f2ebe328621d96ae13f35da2ed36cc55c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.monopriceaudio.internal.handler;
14
15 import static org.openhab.binding.monopriceaudio.internal.MonopriceAudioBindingConstants.*;
16
17 import java.math.BigDecimal;
18 import java.util.ArrayList;
19 import java.util.HashSet;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.Set;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
27 import java.util.stream.Collectors;
28 import java.util.stream.IntStream;
29 import java.util.stream.Stream;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.monopriceaudio.internal.MonopriceAudioException;
34 import org.openhab.binding.monopriceaudio.internal.MonopriceAudioStateDescriptionOptionProvider;
35 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioCommand;
36 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioConnector;
37 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioDefaultConnector;
38 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioIpConnector;
39 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioMessageEvent;
40 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioMessageEventListener;
41 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioSerialConnector;
42 import org.openhab.binding.monopriceaudio.internal.communication.MonopriceAudioZone;
43 import org.openhab.binding.monopriceaudio.internal.configuration.MonopriceAudioThingConfiguration;
44 import org.openhab.binding.monopriceaudio.internal.dto.MonopriceAudioZoneDTO;
45 import org.openhab.core.io.transport.serial.SerialPortManager;
46 import org.openhab.core.library.types.DecimalType;
47 import org.openhab.core.library.types.OnOffType;
48 import org.openhab.core.library.types.OpenClosedType;
49 import org.openhab.core.library.types.PercentType;
50 import org.openhab.core.thing.Channel;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.openhab.core.types.State;
59 import org.openhab.core.types.StateOption;
60 import org.openhab.core.types.UnDefType;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
63
64 /**
65  * The {@link MonopriceAudioHandler} is responsible for handling commands, which are sent to one of the channels.
66  *
67  * Based on the Rotel binding by Laurent Garnier
68  *
69  * @author Michael Lobstein - Initial contribution
70  */
71 @NonNullByDefault
72 public class MonopriceAudioHandler extends BaseThingHandler implements MonopriceAudioMessageEventListener {
73     private static final long RECON_POLLING_INTERVAL_SEC = 60;
74     private static final long INITIAL_POLLING_DELAY_SEC = 5;
75     private static final Pattern PATTERN = Pattern
76             .compile("^(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})");
77
78     private static final String ZONE = "ZONE";
79     private static final String ALL = "all";
80     private static final String CHANNEL_DELIMIT = "#";
81     private static final String ON_STR = "01";
82     private static final String OFF_STR = "00";
83
84     private static final int ONE = 1;
85     private static final int MAX_ZONES = 18;
86     private static final int MAX_SRC = 6;
87     private static final int MIN_VOLUME = 0;
88     private static final int MAX_VOLUME = 38;
89     private static final int MIN_TONE = -7;
90     private static final int MAX_TONE = 7;
91     private static final int MIN_BALANCE = -10;
92     private static final int MAX_BALANCE = 10;
93     private static final int BALANCE_OFFSET = 10;
94     private static final int TONE_OFFSET = 7;
95
96     // build a Map with a MonopriceAudioZoneDTO for each zoneId
97     private final Map<String, MonopriceAudioZoneDTO> zoneDataMap = MonopriceAudioZone.VALID_ZONE_IDS.stream()
98             .collect(Collectors.toMap(s -> s, s -> new MonopriceAudioZoneDTO()));
99
100     private final Logger logger = LoggerFactory.getLogger(MonopriceAudioHandler.class);
101     private final MonopriceAudioStateDescriptionOptionProvider stateDescriptionProvider;
102     private final SerialPortManager serialPortManager;
103
104     private @Nullable ScheduledFuture<?> reconnectJob;
105     private @Nullable ScheduledFuture<?> pollingJob;
106
107     private MonopriceAudioConnector connector = new MonopriceAudioDefaultConnector();
108
109     private Set<String> ignoreZones = new HashSet<>();
110     private long lastPollingUpdate = System.currentTimeMillis();
111     private long pollingInterval = 0;
112     private int numZones = 0;
113     private int allVolume = 1;
114     private int initialAllVolume = 0;
115     private Object sequenceLock = new Object();
116
117     public MonopriceAudioHandler(Thing thing, MonopriceAudioStateDescriptionOptionProvider stateDescriptionProvider,
118             SerialPortManager serialPortManager) {
119         super(thing);
120         this.stateDescriptionProvider = stateDescriptionProvider;
121         this.serialPortManager = serialPortManager;
122     }
123
124     @Override
125     public void initialize() {
126         final String uid = this.getThing().getUID().getAsString();
127         MonopriceAudioThingConfiguration config = getConfigAs(MonopriceAudioThingConfiguration.class);
128         final String serialPort = config.serialPort;
129         final String host = config.host;
130         final Integer port = config.port;
131         final String ignoreZonesConfig = config.ignoreZones;
132
133         // Check configuration settings
134         String configError = null;
135         if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
136             configError = "undefined serialPort and host configuration settings; please set one of them";
137         } else if (serialPort != null && (host == null || host.isEmpty())) {
138             if (serialPort.toLowerCase().startsWith("rfc2217")) {
139                 configError = "use host and port configuration settings for a serial over IP connection";
140             }
141         } else {
142             if (port == null) {
143                 configError = "undefined port configuration setting";
144             } else if (port <= 0) {
145                 configError = "invalid port configuration setting";
146             }
147         }
148
149         if (configError != null) {
150             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
151             return;
152         }
153
154         if (serialPort != null) {
155             connector = new MonopriceAudioSerialConnector(serialPortManager, serialPort, uid);
156         } else if (port != null) {
157             connector = new MonopriceAudioIpConnector(host, port, uid);
158         } else {
159             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
160                     "Either Serial port or Host & Port must be specifed");
161             return;
162         }
163
164         pollingInterval = config.pollingInterval;
165         numZones = config.numZones;
166         initialAllVolume = config.initialAllVolume;
167
168         // If zones were specified to be ignored by the 'all*' commands, use the specified binding
169         // zone ids to get the controller's internal zone ids and save those to a list
170         if (ignoreZonesConfig != null) {
171             for (String zone : ignoreZonesConfig.split(",")) {
172                 try {
173                     int zoneInt = Integer.parseInt(zone);
174                     if (zoneInt >= ONE && zoneInt <= MAX_ZONES) {
175                         ignoreZones.add(ZONE + zoneInt);
176                     } else {
177                         logger.warn("Invalid ignore zone value: {}, value must be between {} and {}", zone, ONE,
178                                 MAX_ZONES);
179                     }
180                 } catch (NumberFormatException nfe) {
181                     logger.warn("Invalid ignore zone value: {}", zone);
182                 }
183             }
184         }
185
186         // Build a state option list for the source labels
187         List<StateOption> sourcesLabels = new ArrayList<>();
188         sourcesLabels.add(new StateOption("1", config.inputLabel1));
189         sourcesLabels.add(new StateOption("2", config.inputLabel2));
190         sourcesLabels.add(new StateOption("3", config.inputLabel3));
191         sourcesLabels.add(new StateOption("4", config.inputLabel4));
192         sourcesLabels.add(new StateOption("5", config.inputLabel5));
193         sourcesLabels.add(new StateOption("6", config.inputLabel6));
194
195         // Put the source labels on all active zones
196         List<Integer> activeZones = IntStream.range(1, numZones + 1).boxed().collect(Collectors.toList());
197
198         stateDescriptionProvider.setStateOptions(
199                 new ChannelUID(getThing().getUID(), ALL + CHANNEL_DELIMIT + CHANNEL_TYPE_ALLSOURCE), sourcesLabels);
200         activeZones.forEach(zoneNum -> {
201             stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(),
202                     ZONE.toLowerCase() + zoneNum + CHANNEL_DELIMIT + CHANNEL_TYPE_SOURCE), sourcesLabels);
203         });
204
205         // remove the channels for the zones we are not using
206         if (numZones < MAX_ZONES) {
207             List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
208
209             List<Integer> zonesToRemove = IntStream.range(numZones + 1, MAX_ZONES + 1).boxed()
210                     .collect(Collectors.toList());
211
212             zonesToRemove.forEach(zone -> {
213                 channels.removeIf(c -> (c.getUID().getId().contains(ZONE.toLowerCase() + zone)));
214             });
215             updateThing(editThing().withChannels(channels).build());
216         }
217
218         // initialize the all volume state
219         allVolume = initialAllVolume;
220         long allVolumePct = Math
221                 .round((double) (initialAllVolume - MIN_VOLUME) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
222         updateState(ALL + CHANNEL_DELIMIT + CHANNEL_TYPE_ALLVOLUME, new PercentType(BigDecimal.valueOf(allVolumePct)));
223
224         scheduleReconnectJob();
225         schedulePollingJob();
226
227         updateStatus(ThingStatus.UNKNOWN);
228     }
229
230     @Override
231     public void dispose() {
232         cancelReconnectJob();
233         cancelPollingJob();
234         closeConnection();
235         ignoreZones.clear();
236     }
237
238     @Override
239     public void handleCommand(ChannelUID channelUID, Command command) {
240         String channel = channelUID.getId();
241         String[] channelSplit = channel.split(CHANNEL_DELIMIT);
242         MonopriceAudioZone zone = MonopriceAudioZone.valueOf(channelSplit[0].toUpperCase());
243         String channelType = channelSplit[1];
244
245         if (getThing().getStatus() != ThingStatus.ONLINE) {
246             logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
247             return;
248         }
249
250         boolean success = true;
251         synchronized (sequenceLock) {
252             if (!connector.isConnected()) {
253                 logger.debug("Command {} from channel {} is ignored: connection not established", command, channel);
254                 return;
255             }
256
257             if (command instanceof RefreshType) {
258                 MonopriceAudioZoneDTO zoneDTO = zoneDataMap.get(zone.getZoneId());
259                 if (zoneDTO != null) {
260                     updateChannelState(zone, channelType, zoneDTO);
261                 } else {
262                     logger.info("Could not execute REFRESH command for zone {}: null", zone.getZoneId());
263                 }
264                 return;
265             }
266
267             Stream<String> zoneStream = MonopriceAudioZone.VALID_ZONES.stream().limit(numZones);
268             try {
269                 switch (channelType) {
270                     case CHANNEL_TYPE_POWER:
271                         if (command instanceof OnOffType) {
272                             connector.sendCommand(zone, MonopriceAudioCommand.POWER, command == OnOffType.ON ? 1 : 0);
273                             zoneDataMap.get(zone.getZoneId()).setPower(command == OnOffType.ON ? ON_STR : OFF_STR);
274                         }
275                         break;
276                     case CHANNEL_TYPE_SOURCE:
277                         if (command instanceof DecimalType) {
278                             int value = ((DecimalType) command).intValue();
279                             if (value >= ONE && value <= MAX_SRC) {
280                                 logger.debug("Got source command {} zone {}", value, zone);
281                                 connector.sendCommand(zone, MonopriceAudioCommand.SOURCE, value);
282                                 zoneDataMap.get(zone.getZoneId()).setSource(String.format("%02d", value));
283                             }
284                         }
285                         break;
286                     case CHANNEL_TYPE_VOLUME:
287                         if (command instanceof PercentType) {
288                             int value = (int) Math
289                                     .round(((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
290                                     + MIN_VOLUME;
291                             logger.debug("Got volume command {} zone {}", value, zone);
292                             connector.sendCommand(zone, MonopriceAudioCommand.VOLUME, value);
293                             zoneDataMap.get(zone.getZoneId()).setVolume(value);
294                         }
295                         break;
296                     case CHANNEL_TYPE_MUTE:
297                         if (command instanceof OnOffType) {
298                             connector.sendCommand(zone, MonopriceAudioCommand.MUTE, command == OnOffType.ON ? 1 : 0);
299                             zoneDataMap.get(zone.getZoneId()).setMute(command == OnOffType.ON ? ON_STR : OFF_STR);
300                         }
301                         break;
302                     case CHANNEL_TYPE_TREBLE:
303                         if (command instanceof DecimalType) {
304                             int value = ((DecimalType) command).intValue();
305                             if (value >= MIN_TONE && value <= MAX_TONE) {
306                                 logger.debug("Got treble command {} zone {}", value, zone);
307                                 connector.sendCommand(zone, MonopriceAudioCommand.TREBLE, value + TONE_OFFSET);
308                                 zoneDataMap.get(zone.getZoneId()).setTreble(value + TONE_OFFSET);
309                             }
310                         }
311                         break;
312                     case CHANNEL_TYPE_BASS:
313                         if (command instanceof DecimalType) {
314                             int value = ((DecimalType) command).intValue();
315                             if (value >= MIN_TONE && value <= MAX_TONE) {
316                                 logger.debug("Got bass command {} zone {}", value, zone);
317                                 connector.sendCommand(zone, MonopriceAudioCommand.BASS, value + TONE_OFFSET);
318                                 zoneDataMap.get(zone.getZoneId()).setBass(value + TONE_OFFSET);
319                             }
320                         }
321                         break;
322                     case CHANNEL_TYPE_BALANCE:
323                         if (command instanceof DecimalType) {
324                             int value = ((DecimalType) command).intValue();
325                             if (value >= MIN_BALANCE && value <= MAX_BALANCE) {
326                                 logger.debug("Got balance command {} zone {}", value, zone);
327                                 connector.sendCommand(zone, MonopriceAudioCommand.BALANCE, value + BALANCE_OFFSET);
328                                 zoneDataMap.get(zone.getZoneId()).setBalance(value + BALANCE_OFFSET);
329                             }
330                         }
331                         break;
332                     case CHANNEL_TYPE_DND:
333                         if (command instanceof OnOffType) {
334                             connector.sendCommand(zone, MonopriceAudioCommand.DND, command == OnOffType.ON ? 1 : 0);
335                             zoneDataMap.get(zone.getZoneId()).setDnd(command == OnOffType.ON ? ON_STR : OFF_STR);
336                         }
337                         break;
338                     case CHANNEL_TYPE_ALLPOWER:
339                         if (command instanceof OnOffType) {
340                             zoneStream.forEach((zoneName) -> {
341                                 if (command == OnOffType.OFF || !ignoreZones.contains(zoneName)) {
342                                     try {
343                                         connector.sendCommand(MonopriceAudioZone.valueOf(zoneName),
344                                                 MonopriceAudioCommand.POWER, command == OnOffType.ON ? 1 : 0);
345                                         if (command == OnOffType.ON) {
346                                             // reset the volume of each zone to allVolume
347                                             connector.sendCommand(MonopriceAudioZone.valueOf(zoneName),
348                                                     MonopriceAudioCommand.VOLUME, allVolume);
349                                         }
350                                     } catch (MonopriceAudioException e) {
351                                         logger.warn("Error Turning All Zones On: {}", e.getMessage());
352                                     }
353                                 }
354
355                             });
356                         }
357                         break;
358                     case CHANNEL_TYPE_ALLSOURCE:
359                         if (command instanceof DecimalType) {
360                             int value = ((DecimalType) command).intValue();
361                             if (value >= ONE && value <= MAX_SRC) {
362                                 zoneStream.forEach((zoneName) -> {
363                                     if (!ignoreZones.contains(zoneName)) {
364                                         try {
365                                             connector.sendCommand(MonopriceAudioZone.valueOf(zoneName),
366                                                     MonopriceAudioCommand.SOURCE, value);
367                                         } catch (MonopriceAudioException e) {
368                                             logger.warn("Error Setting Source for  All Zones: {}", e.getMessage());
369                                         }
370                                     }
371                                 });
372                             }
373                         }
374                         break;
375                     case CHANNEL_TYPE_ALLVOLUME:
376                         if (command instanceof PercentType) {
377                             int value = (int) Math
378                                     .round(((PercentType) command).doubleValue() / 100.0 * (MAX_VOLUME - MIN_VOLUME))
379                                     + MIN_VOLUME;
380                             allVolume = value;
381                             zoneStream.forEach((zoneName) -> {
382                                 if (!ignoreZones.contains(zoneName)) {
383                                     try {
384                                         connector.sendCommand(MonopriceAudioZone.valueOf(zoneName),
385                                                 MonopriceAudioCommand.VOLUME, value);
386                                     } catch (MonopriceAudioException e) {
387                                         logger.warn("Error Setting Volume for All Zones: {}", e.getMessage());
388                                     }
389                                 }
390                             });
391                         }
392                         break;
393                     case CHANNEL_TYPE_ALLMUTE:
394                         if (command instanceof OnOffType) {
395                             int cmd = command == OnOffType.ON ? 1 : 0;
396                             zoneStream.forEach((zoneName) -> {
397                                 if (!ignoreZones.contains(zoneName)) {
398                                     try {
399                                         connector.sendCommand(MonopriceAudioZone.valueOf(zoneName),
400                                                 MonopriceAudioCommand.MUTE, cmd);
401                                     } catch (MonopriceAudioException e) {
402                                         logger.warn("Error Setting Mute for All Zones: {}", e.getMessage());
403                                     }
404                                 }
405                             });
406                         }
407                         break;
408                     default:
409                         success = false;
410                         logger.debug("Command {} from channel {} failed: unexpected command", command, channel);
411                         break;
412                 }
413
414                 if (success) {
415                     logger.trace("Command {} from channel {} succeeded", command, channel);
416                 }
417             } catch (MonopriceAudioException e) {
418                 logger.warn("Command {} from channel {} failed: {}", command, channel, e.getMessage());
419                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
420                 closeConnection();
421                 scheduleReconnectJob();
422             }
423         }
424     }
425
426     /**
427      * Open the connection with the MonopriceAudio device
428      *
429      * @return true if the connection is opened successfully or false if not
430      */
431     private synchronized boolean openConnection() {
432         connector.addEventListener(this);
433         try {
434             connector.open();
435         } catch (MonopriceAudioException e) {
436             logger.debug("openConnection() failed: {}", e.getMessage());
437         }
438         logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
439         return connector.isConnected();
440     }
441
442     /**
443      * Close the connection with the MonopriceAudio device
444      */
445     private synchronized void closeConnection() {
446         if (connector.isConnected()) {
447             connector.close();
448             connector.removeEventListener(this);
449             logger.debug("closeConnection(): disconnected");
450         }
451     }
452
453     @Override
454     public void onNewMessageEvent(MonopriceAudioMessageEvent evt) {
455         String key = evt.getKey();
456         String updateData = evt.getValue().trim();
457         if (!MonopriceAudioConnector.KEY_ERROR.equals(key)) {
458             updateStatus(ThingStatus.ONLINE);
459         }
460         try {
461             switch (key) {
462                 case MonopriceAudioConnector.KEY_ERROR:
463                     logger.debug("Reading feedback message failed");
464                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Reading thread ended");
465                     closeConnection();
466                     break;
467
468                 case MonopriceAudioConnector.KEY_ZONE_UPDATE:
469                     String zoneId = updateData.substring(0, 2);
470                     MonopriceAudioZoneDTO zoneDTO = zoneDataMap.get(zoneId);
471                     if (MonopriceAudioZone.VALID_ZONE_IDS.contains(zoneId) && zoneDTO != null) {
472                         MonopriceAudioZone targetZone = MonopriceAudioZone.fromZoneId(zoneId);
473                         processZoneUpdate(targetZone, zoneDTO, updateData);
474                     } else {
475                         logger.warn("invalid event: {} for key: {} or zone data null", evt.getValue(), key);
476                     }
477                     break;
478                 default:
479                     logger.debug("onNewMessageEvent: unhandled key {}", key);
480                     break;
481             }
482         } catch (NumberFormatException e) {
483             logger.warn("Invalid value {} for key {}", updateData, key);
484         } catch (MonopriceAudioException e) {
485             logger.warn("Error processing zone update: {}", e.getMessage());
486         }
487     }
488
489     /**
490      * Schedule the reconnection job
491      */
492     private void scheduleReconnectJob() {
493         logger.debug("Schedule reconnect job");
494         cancelReconnectJob();
495         reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
496             synchronized (sequenceLock) {
497                 if (!connector.isConnected()) {
498                     logger.debug("Trying to reconnect...");
499                     closeConnection();
500                     String error = null;
501
502                     if (openConnection()) {
503                         try {
504                             long prevUpdateTime = lastPollingUpdate;
505                             connector.queryZone(MonopriceAudioZone.ZONE1);
506
507                             // prevUpdateTime should have changed if a zone update was received
508                             if (lastPollingUpdate == prevUpdateTime) {
509                                 error = "Controller not responding to status requests";
510                             }
511
512                         } catch (MonopriceAudioException e) {
513                             error = "First command after connection failed";
514                             logger.warn("{}: {}", error, e.getMessage());
515                             closeConnection();
516                         }
517                     } else {
518                         error = "Reconnection failed";
519                     }
520                     if (error != null) {
521                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
522                     } else {
523                         updateStatus(ThingStatus.ONLINE);
524                         lastPollingUpdate = System.currentTimeMillis();
525                     }
526                 }
527             }
528         }, 1, RECON_POLLING_INTERVAL_SEC, TimeUnit.SECONDS);
529     }
530
531     /**
532      * Cancel the reconnection job
533      */
534     private void cancelReconnectJob() {
535         ScheduledFuture<?> reconnectJob = this.reconnectJob;
536         if (reconnectJob != null) {
537             reconnectJob.cancel(true);
538             this.reconnectJob = null;
539         }
540     }
541
542     /**
543      * Schedule the polling job
544      */
545     private void schedulePollingJob() {
546         logger.debug("Schedule polling job");
547         cancelPollingJob();
548
549         pollingJob = scheduler.scheduleWithFixedDelay(() -> {
550             synchronized (sequenceLock) {
551                 if (connector.isConnected()) {
552                     logger.debug("Polling the controller for updated status...");
553
554                     // poll each zone up to the number of zones specified in the configuration
555                     MonopriceAudioZone.VALID_ZONES.stream().limit(numZones).forEach((zoneName) -> {
556                         try {
557                             connector.queryZone(MonopriceAudioZone.valueOf(zoneName));
558                         } catch (MonopriceAudioException e) {
559                             logger.warn("Polling error: {}", e.getMessage());
560                         }
561                     });
562
563                     // if the last successful polling update was more than 2.25 intervals ago, the controller
564                     // is either switched off or not responding even though the connection is still good
565                     if ((System.currentTimeMillis() - lastPollingUpdate) > (pollingInterval * 2.25 * 1000)) {
566                         logger.warn("Controller not responding to status requests");
567                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
568                                 "Controller not responding to status requests");
569                         closeConnection();
570                         scheduleReconnectJob();
571                     }
572                 }
573             }
574         }, INITIAL_POLLING_DELAY_SEC, pollingInterval, TimeUnit.SECONDS);
575     }
576
577     /**
578      * Cancel the polling job
579      */
580     private void cancelPollingJob() {
581         ScheduledFuture<?> pollingJob = this.pollingJob;
582         if (pollingJob != null) {
583             pollingJob.cancel(true);
584             this.pollingJob = null;
585         }
586     }
587
588     /**
589      * Update the state of a channel
590      *
591      * @param channel the channel
592      */
593     private void updateChannelState(MonopriceAudioZone zone, String channelType, MonopriceAudioZoneDTO zoneData) {
594         String channel = zone.name().toLowerCase() + CHANNEL_DELIMIT + channelType;
595
596         if (!isLinked(channel)) {
597             return;
598         }
599
600         State state = UnDefType.UNDEF;
601         switch (channelType) {
602             case CHANNEL_TYPE_POWER:
603                 state = zoneData.isPowerOn() ? OnOffType.ON : OnOffType.OFF;
604                 break;
605             case CHANNEL_TYPE_SOURCE:
606                 state = new DecimalType(zoneData.getSource());
607                 break;
608             case CHANNEL_TYPE_VOLUME:
609                 long volumePct = Math.round(
610                         (double) (zoneData.getVolume() - MIN_VOLUME) / (double) (MAX_VOLUME - MIN_VOLUME) * 100.0);
611                 state = new PercentType(BigDecimal.valueOf(volumePct));
612                 break;
613             case CHANNEL_TYPE_MUTE:
614                 state = zoneData.isMuted() ? OnOffType.ON : OnOffType.OFF;
615                 break;
616             case CHANNEL_TYPE_TREBLE:
617                 state = new DecimalType(BigDecimal.valueOf(zoneData.getTreble() - TONE_OFFSET));
618                 break;
619             case CHANNEL_TYPE_BASS:
620                 state = new DecimalType(BigDecimal.valueOf(zoneData.getBass() - TONE_OFFSET));
621                 break;
622             case CHANNEL_TYPE_BALANCE:
623                 state = new DecimalType(BigDecimal.valueOf(zoneData.getBalance() - BALANCE_OFFSET));
624                 break;
625             case CHANNEL_TYPE_DND:
626                 state = zoneData.isDndOn() ? OnOffType.ON : OnOffType.OFF;
627                 break;
628             case CHANNEL_TYPE_PAGE:
629                 state = zoneData.isPageActive() ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
630                 break;
631             case CHANNEL_TYPE_KEYPAD:
632                 state = zoneData.isKeypadActive() ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
633                 break;
634             default:
635                 break;
636         }
637         updateState(channel, state);
638     }
639
640     private void processZoneUpdate(MonopriceAudioZone zone, MonopriceAudioZoneDTO zoneData, String newZoneData) {
641         // only process the update if something actually changed in this zone since the last time through
642         if (!newZoneData.equals(zoneData.toString())) {
643             // example status string: 1200010000130809100601, matcher pattern from above:
644             // "^(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})"
645             Matcher matcher = PATTERN.matcher(newZoneData);
646             if (matcher.find()) {
647                 zoneData.setZone(matcher.group(1));
648
649                 if (!matcher.group(2).equals(zoneData.getPage())) {
650                     zoneData.setPage(matcher.group(2));
651                     updateChannelState(zone, CHANNEL_TYPE_PAGE, zoneData);
652                 }
653
654                 if (!matcher.group(3).equals(zoneData.getPower())) {
655                     zoneData.setPower(matcher.group(3));
656                     updateChannelState(zone, CHANNEL_TYPE_POWER, zoneData);
657                 }
658
659                 if (!matcher.group(4).equals(zoneData.getMute())) {
660                     zoneData.setMute(matcher.group(4));
661                     updateChannelState(zone, CHANNEL_TYPE_MUTE, zoneData);
662                 }
663
664                 if (!matcher.group(5).equals(zoneData.getDnd())) {
665                     zoneData.setDnd(matcher.group(5));
666                     updateChannelState(zone, CHANNEL_TYPE_DND, zoneData);
667                 }
668
669                 int volume = Integer.parseInt(matcher.group(6));
670                 if (volume != zoneData.getVolume()) {
671                     zoneData.setVolume(volume);
672                     updateChannelState(zone, CHANNEL_TYPE_VOLUME, zoneData);
673                 }
674
675                 int treble = Integer.parseInt(matcher.group(7));
676                 if (treble != zoneData.getTreble()) {
677                     zoneData.setTreble(treble);
678                     updateChannelState(zone, CHANNEL_TYPE_TREBLE, zoneData);
679                 }
680
681                 int bass = Integer.parseInt(matcher.group(8));
682                 if (bass != zoneData.getBass()) {
683                     zoneData.setBass(bass);
684                     updateChannelState(zone, CHANNEL_TYPE_BASS, zoneData);
685                 }
686
687                 int balance = Integer.parseInt(matcher.group(9));
688                 if (balance != zoneData.getBalance()) {
689                     zoneData.setBalance(balance);
690                     updateChannelState(zone, CHANNEL_TYPE_BALANCE, zoneData);
691                 }
692
693                 if (!matcher.group(10).equals(zoneData.getSource())) {
694                     zoneData.setSource(matcher.group(10));
695                     updateChannelState(zone, CHANNEL_TYPE_SOURCE, zoneData);
696                 }
697
698                 if (!matcher.group(11).equals(zoneData.getKeypad())) {
699                     zoneData.setKeypad(matcher.group(11));
700                     updateChannelState(zone, CHANNEL_TYPE_KEYPAD, zoneData);
701                 }
702             } else {
703                 logger.debug("Invalid zone update message: {}", newZoneData);
704             }
705
706         }
707         lastPollingUpdate = System.currentTimeMillis();
708     }
709 }