]> git.basschouten.com Git - openhab-addons.git/blob
0be9348c12d9cd95f8e17fbe156d288a1da9b63e
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.denonmarantz.internal.handler;
14
15 import static org.openhab.binding.denonmarantz.internal.DenonMarantzBindingConstants.*;
16
17 import java.io.IOException;
18 import java.io.StringReader;
19 import java.net.HttpURLConnection;
20 import java.util.ArrayList;
21 import java.util.HashSet;
22 import java.util.List;
23 import java.util.Map.Entry;
24 import java.util.Set;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29
30 import javax.xml.parsers.DocumentBuilder;
31 import javax.xml.parsers.DocumentBuilderFactory;
32 import javax.xml.parsers.ParserConfigurationException;
33 import javax.xml.xpath.XPath;
34 import javax.xml.xpath.XPathConstants;
35 import javax.xml.xpath.XPathExpressionException;
36 import javax.xml.xpath.XPathFactory;
37
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.api.ContentResponse;
40 import org.openhab.binding.denonmarantz.internal.DenonMarantzState;
41 import org.openhab.binding.denonmarantz.internal.DenonMarantzStateChangedListener;
42 import org.openhab.binding.denonmarantz.internal.UnsupportedCommandTypeException;
43 import org.openhab.binding.denonmarantz.internal.config.DenonMarantzConfiguration;
44 import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnector;
45 import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnectorFactory;
46 import org.openhab.binding.denonmarantz.internal.connector.http.DenonMarantzHttpConnector;
47 import org.openhab.core.config.core.Configuration;
48 import org.openhab.core.thing.Channel;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.thing.binding.builder.ChannelBuilder;
55 import org.openhab.core.thing.type.ChannelTypeUID;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.openhab.core.types.State;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
61 import org.w3c.dom.Document;
62 import org.w3c.dom.Node;
63 import org.xml.sax.InputSource;
64 import org.xml.sax.SAXException;
65
66 /**
67  * The {@link DenonMarantzHandler} is responsible for handling commands, which are
68  * sent to one of the channels.
69  *
70  * @author Jan-Willem Veldhuis - Initial contribution
71  */
72 public class DenonMarantzHandler extends BaseThingHandler implements DenonMarantzStateChangedListener {
73
74     private final Logger logger = LoggerFactory.getLogger(DenonMarantzHandler.class);
75     private static final int RETRY_TIME_SECONDS = 30;
76     private HttpClient httpClient;
77     private DenonMarantzConnector connector;
78     private DenonMarantzConfiguration config;
79     private DenonMarantzConnectorFactory connectorFactory = new DenonMarantzConnectorFactory();
80     private DenonMarantzState denonMarantzState;
81     private ScheduledFuture<?> retryJob;
82
83     public DenonMarantzHandler(Thing thing, HttpClient httpClient) {
84         super(thing);
85         this.httpClient = httpClient;
86     }
87
88     @Override
89     public void handleCommand(ChannelUID channelUID, Command command) {
90         if (connector == null) {
91             return;
92         }
93
94         if (connector instanceof DenonMarantzHttpConnector && command instanceof RefreshType) {
95             // Refreshing individual channels isn't supported by the Http connector.
96             // The connector refreshes all channels together at the configured polling interval.
97             return;
98         }
99
100         try {
101             switch (channelUID.getId()) {
102                 case CHANNEL_POWER:
103                     connector.sendPowerCommand(command, 0);
104                     break;
105                 case CHANNEL_MAIN_ZONE_POWER:
106                     connector.sendPowerCommand(command, 1);
107                     break;
108                 case CHANNEL_MUTE:
109                     connector.sendMuteCommand(command, 1);
110                     break;
111                 case CHANNEL_MAIN_VOLUME:
112                     connector.sendVolumeCommand(command, 1);
113                     break;
114                 case CHANNEL_MAIN_VOLUME_DB:
115                     connector.sendVolumeDbCommand(command, 1);
116                     break;
117                 case CHANNEL_INPUT:
118                     connector.sendInputCommand(command, 1);
119                     break;
120                 case CHANNEL_SURROUND_PROGRAM:
121                     connector.sendSurroundProgramCommand(command);
122                     break;
123                 case CHANNEL_COMMAND:
124                     connector.sendCustomCommand(command);
125                     break;
126
127                 case CHANNEL_ZONE2_POWER:
128                     connector.sendPowerCommand(command, 2);
129                     break;
130                 case CHANNEL_ZONE2_MUTE:
131                     connector.sendMuteCommand(command, 2);
132                     break;
133                 case CHANNEL_ZONE2_VOLUME:
134                     connector.sendVolumeCommand(command, 2);
135                     break;
136                 case CHANNEL_ZONE2_VOLUME_DB:
137                     connector.sendVolumeDbCommand(command, 2);
138                     break;
139                 case CHANNEL_ZONE2_INPUT:
140                     connector.sendInputCommand(command, 2);
141                     break;
142
143                 case CHANNEL_ZONE3_POWER:
144                     connector.sendPowerCommand(command, 3);
145                     break;
146                 case CHANNEL_ZONE3_MUTE:
147                     connector.sendMuteCommand(command, 3);
148                     break;
149                 case CHANNEL_ZONE3_VOLUME:
150                     connector.sendVolumeCommand(command, 3);
151                     break;
152                 case CHANNEL_ZONE3_VOLUME_DB:
153                     connector.sendVolumeDbCommand(command, 3);
154                     break;
155                 case CHANNEL_ZONE3_INPUT:
156                     connector.sendInputCommand(command, 3);
157                     break;
158
159                 case CHANNEL_ZONE4_POWER:
160                     connector.sendPowerCommand(command, 4);
161                     break;
162                 case CHANNEL_ZONE4_MUTE:
163                     connector.sendMuteCommand(command, 4);
164                     break;
165                 case CHANNEL_ZONE4_VOLUME:
166                     connector.sendVolumeCommand(command, 4);
167                     break;
168                 case CHANNEL_ZONE4_VOLUME_DB:
169                     connector.sendVolumeDbCommand(command, 4);
170                     break;
171                 case CHANNEL_ZONE4_INPUT:
172                     connector.sendInputCommand(command, 4);
173                     break;
174
175                 default:
176                     throw new UnsupportedCommandTypeException();
177             }
178         } catch (UnsupportedCommandTypeException e) {
179             logger.debug("Unsupported command {} for channel {}", command, channelUID.getId());
180         }
181     }
182
183     public boolean checkConfiguration() {
184         // prevent too low values for polling interval
185         if (config.httpPollingInterval < 5) {
186             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
187                     "The polling interval should be at least 5 seconds!");
188             return false;
189         }
190         // Check zone count is within supported range
191         if (config.getZoneCount() < 1 || config.getZoneCount() > 4) {
192             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
193                     "This binding supports 1 to 4 zones. Please update the zone count.");
194             return false;
195         }
196         return true;
197     }
198
199     /**
200      * Try to auto configure the connection type (Telnet or HTTP)
201      * for Things not added through Paper UI.
202      */
203     private void autoConfigure() throws InterruptedException {
204         /*
205          * The isTelnet parameter has no default.
206          * When not set we will try to auto-detect the correct values
207          * for isTelnet and zoneCount and update the Thing accordingly.
208          */
209         if (config.isTelnet() == null) {
210             logger.debug("Trying to auto-detect the connection.");
211             ContentResponse response;
212             boolean telnetEnable = true;
213             int httpPort = 80;
214             boolean httpApiUsable = false;
215
216             // try to reach the HTTP API at port 80 (most models, except Denon ...H should respond.
217             String host = config.getHost();
218             try {
219                 response = httpClient.newRequest("http://" + host + "/goform/Deviceinfo.xml")
220                         .timeout(3, TimeUnit.SECONDS).send();
221                 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
222                     logger.debug("We can access the HTTP API, disabling the Telnet mode by default.");
223                     telnetEnable = false;
224                     httpApiUsable = true;
225                 }
226             } catch (TimeoutException | ExecutionException e) {
227                 logger.debug("Error when trying to access AVR using HTTP on port 80, reverting to Telnet mode.", e);
228             }
229
230             if (telnetEnable) {
231                 // the above attempt failed. Let's try on port 8080, as for some models a subset of the HTTP API is
232                 // available
233                 try {
234                     response = httpClient.newRequest("http://" + host + ":8080/goform/Deviceinfo.xml")
235                             .timeout(3, TimeUnit.SECONDS).send();
236                     if (response.getStatus() == HttpURLConnection.HTTP_OK) {
237                         logger.debug(
238                                 "This model responds to HTTP port 8080, we use this port to retrieve the number of zones.");
239                         httpPort = 8080;
240                         httpApiUsable = true;
241                     }
242                 } catch (TimeoutException | ExecutionException e) {
243                     logger.debug("Additionally tried to connect to port 8080, this also failed", e);
244                 }
245             }
246
247             // default zone count
248             int zoneCount = 2;
249
250             // try to determine the zone count by checking the Deviceinfo.xml file
251             if (httpApiUsable) {
252                 int status = 0;
253                 response = null;
254                 try {
255                     response = httpClient.newRequest("http://" + host + ":" + httpPort + "/goform/Deviceinfo.xml")
256                             .timeout(3, TimeUnit.SECONDS).send();
257                     status = response.getStatus();
258                 } catch (TimeoutException | ExecutionException e) {
259                     logger.debug("Failed in fetching the Deviceinfo.xml to determine zone count", e);
260                 }
261
262                 if (status == HttpURLConnection.HTTP_OK && response != null) {
263                     DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
264                     try {
265                         // see
266                         // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
267                         domFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
268                         domFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
269                         domFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
270                         domFactory.setXIncludeAware(false);
271                         domFactory.setExpandEntityReferences(false);
272                         DocumentBuilder builder;
273                         builder = domFactory.newDocumentBuilder();
274                         Document dDoc = builder.parse(new InputSource(new StringReader(response.getContentAsString())));
275                         XPath xPath = XPathFactory.newInstance().newXPath();
276                         Node node = (Node) xPath.evaluate("/Device_Info/DeviceZones/text()", dDoc, XPathConstants.NODE);
277                         if (node != null) {
278                             String nodeValue = node.getNodeValue();
279                             logger.trace("/Device_Info/DeviceZones/text() = {}", nodeValue);
280                             zoneCount = Integer.parseInt(nodeValue);
281                             logger.debug("Discovered number of zones: {}", zoneCount);
282                         }
283                     } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException
284                             | NumberFormatException e) {
285                         logger.debug("Something went wrong with looking up the zone count in Deviceinfo.xml: {}",
286                                 e.getMessage());
287                     }
288                 }
289             }
290             config.setTelnet(telnetEnable);
291             config.setZoneCount(zoneCount);
292             Configuration configuration = editConfiguration();
293             configuration.put(PARAMETER_TELNET_ENABLED, telnetEnable);
294             configuration.put(PARAMETER_ZONE_COUNT, zoneCount);
295             updateConfiguration(configuration);
296         }
297     }
298
299     @Override
300     public void initialize() {
301         cancelRetry();
302         config = getConfigAs(DenonMarantzConfiguration.class);
303
304         // Configure Connection type (Telnet/HTTP) and number of zones
305         // Note: this only happens for discovered Things
306         try {
307             autoConfigure();
308         } catch (InterruptedException e) {
309             Thread.currentThread().interrupt();
310             return;
311         }
312
313         if (!checkConfiguration()) {
314             return;
315         }
316
317         denonMarantzState = new DenonMarantzState(this);
318         configureZoneChannels();
319         updateStatus(ThingStatus.UNKNOWN);
320         // create connection (either Telnet or HTTP)
321         // ThingStatus ONLINE/OFFLINE is set when AVR status is known.
322         createConnection();
323     }
324
325     private void createConnection() {
326         if (connector != null) {
327             connector.dispose();
328         }
329         connector = connectorFactory.getConnector(config, denonMarantzState, scheduler, httpClient,
330                 this.getThing().getUID().getAsString());
331         connector.connect();
332     }
333
334     private void cancelRetry() {
335         ScheduledFuture<?> localRetryJob = retryJob;
336         if (localRetryJob != null && !localRetryJob.isDone()) {
337             localRetryJob.cancel(false);
338         }
339     }
340
341     private void configureZoneChannels() {
342         logger.debug("Configuring zone channels");
343         Integer zoneCount = config.getZoneCount();
344         List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
345         boolean channelsUpdated = false;
346
347         // construct a set with the existing channel type UIDs, to quickly check
348         Set<String> currentChannels = new HashSet<>();
349         channels.forEach(channel -> currentChannels.add(channel.getUID().getId()));
350
351         Set<Entry<String, ChannelTypeUID>> channelsToRemove = new HashSet<>();
352
353         if (zoneCount > 1) {
354             List<Entry<String, ChannelTypeUID>> channelsToAdd = new ArrayList<>(ZONE2_CHANNEL_TYPES.entrySet());
355
356             if (zoneCount > 2) {
357                 // add channels for zone 3
358                 channelsToAdd.addAll(ZONE3_CHANNEL_TYPES.entrySet());
359                 if (zoneCount > 3) {
360                     // add channels for zone 4 (more zones currently not supported)
361                     channelsToAdd.addAll(ZONE4_CHANNEL_TYPES.entrySet());
362                 } else {
363                     channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
364                 }
365             } else {
366                 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
367                 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
368             }
369
370             // filter out the already existing channels
371             channelsToAdd.removeIf(c -> currentChannels.contains(c.getKey()));
372
373             // add the channels that were not yet added
374             if (!channelsToAdd.isEmpty()) {
375                 for (Entry<String, ChannelTypeUID> entry : channelsToAdd) {
376                     String itemType = CHANNEL_ITEM_TYPES.get(entry.getKey());
377                     Channel channel = ChannelBuilder
378                             .create(new ChannelUID(this.getThing().getUID(), entry.getKey()), itemType)
379                             .withType(entry.getValue()).build();
380                     channels.add(channel);
381                 }
382                 channelsUpdated = true;
383             } else {
384                 logger.debug("No zone channels have been added");
385             }
386         } else {
387             channelsToRemove.addAll(ZONE2_CHANNEL_TYPES.entrySet());
388             channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
389             channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
390         }
391
392         // filter out the non-existing channels
393         channelsToRemove.removeIf(c -> !currentChannels.contains(c.getKey()));
394
395         // remove the channels that were not yet added
396         if (!channelsToRemove.isEmpty()) {
397             for (Entry<String, ChannelTypeUID> entry : channelsToRemove) {
398                 if (channels.removeIf(c -> (entry.getKey()).equals(c.getUID().getId()))) {
399                     logger.trace("Removed channel {}", entry.getKey());
400                 } else {
401                     logger.trace("Could NOT remove channel {}", entry.getKey());
402                 }
403             }
404             channelsUpdated = true;
405         } else {
406             logger.debug("No zone channels have been removed");
407         }
408
409         // update Thing if channels changed
410         if (channelsUpdated) {
411             updateThing(editThing().withChannels(channels).build());
412         }
413     }
414
415     @Override
416     public void dispose() {
417         if (connector != null) {
418             connector.dispose();
419             connector = null;
420         }
421         cancelRetry();
422         super.dispose();
423     }
424
425     @Override
426     public void channelLinked(ChannelUID channelUID) {
427         super.channelLinked(channelUID);
428         String channelID = channelUID.getId();
429         if (isLinked(channelID)) {
430             State state = denonMarantzState.getStateForChannelID(channelID);
431             if (state != null) {
432                 updateState(channelID, state);
433             }
434         }
435     }
436
437     @Override
438     public void stateChanged(String channelID, State state) {
439         logger.debug("Received state {} for channelID {}", state, channelID);
440
441         // Don't flood the log with thing 'updated: ONLINE' each time a single channel changed
442         if (this.getThing().getStatus() != ThingStatus.ONLINE) {
443             updateStatus(ThingStatus.ONLINE);
444         }
445         updateState(channelID, state);
446     }
447
448     @Override
449     public void connectionError(String errorMessage) {
450         if (this.getThing().getStatus() != ThingStatus.OFFLINE) {
451             // Don't flood the log with thing 'updated: OFFLINE' when already offline
452             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
453         }
454         connector.dispose();
455         retryJob = scheduler.schedule(this::createConnection, RETRY_TIME_SECONDS, TimeUnit.SECONDS);
456     }
457 }