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