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