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