]> git.basschouten.com Git - openhab-addons.git/blob
a5288d7a3a6b8634f4de3cbd1a727c256b68b8e4
[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.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) for unmanaged Things.
201      */
202     private void autoConfigure() throws InterruptedException {
203         /*
204          * The isTelnet parameter has no default.
205          * When not set we will try to auto-detect the correct values
206          * for isTelnet and zoneCount and update the Thing accordingly.
207          */
208         if (config.isTelnet() == null) {
209             logger.debug("Trying to auto-detect the connection.");
210             ContentResponse response;
211             boolean telnetEnable = true;
212             int httpPort = 80;
213             boolean httpApiUsable = false;
214
215             // try to reach the HTTP API at port 80 (most models, except Denon ...H should respond.
216             String host = config.getHost();
217             try {
218                 response = httpClient.newRequest("http://" + host + "/goform/Deviceinfo.xml")
219                         .timeout(3, TimeUnit.SECONDS).send();
220                 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
221                     logger.debug("We can access the HTTP API, disabling the Telnet mode by default.");
222                     telnetEnable = false;
223                     httpApiUsable = true;
224                 }
225             } catch (TimeoutException | ExecutionException e) {
226                 logger.debug("Error when trying to access AVR using HTTP on port 80, reverting to Telnet mode.", e);
227             }
228
229             if (telnetEnable) {
230                 // the above attempt failed. Let's try on port 8080, as for some models a subset of the HTTP API is
231                 // available
232                 try {
233                     response = httpClient.newRequest("http://" + host + ":8080/goform/Deviceinfo.xml")
234                             .timeout(3, TimeUnit.SECONDS).send();
235                     if (response.getStatus() == HttpURLConnection.HTTP_OK) {
236                         logger.debug(
237                                 "This model responds to HTTP port 8080, we use this port to retrieve the number of zones.");
238                         httpPort = 8080;
239                         httpApiUsable = true;
240                     }
241                 } catch (TimeoutException | ExecutionException e) {
242                     logger.debug("Additionally tried to connect to port 8080, this also failed", e);
243                 }
244             }
245
246             // default zone count
247             int zoneCount = 2;
248
249             // try to determine the zone count by checking the Deviceinfo.xml file
250             if (httpApiUsable) {
251                 int status = 0;
252                 response = null;
253                 try {
254                     response = httpClient.newRequest("http://" + host + ":" + httpPort + "/goform/Deviceinfo.xml")
255                             .timeout(3, TimeUnit.SECONDS).send();
256                     status = response.getStatus();
257                 } catch (TimeoutException | ExecutionException e) {
258                     logger.debug("Failed in fetching the Deviceinfo.xml to determine zone count", e);
259                 }
260
261                 if (status == HttpURLConnection.HTTP_OK && response != null) {
262                     DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
263                     try {
264                         // see
265                         // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
266                         domFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
267                         domFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
268                         domFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
269                         domFactory.setXIncludeAware(false);
270                         domFactory.setExpandEntityReferences(false);
271                         DocumentBuilder builder;
272                         builder = domFactory.newDocumentBuilder();
273                         Document dDoc = builder.parse(new InputSource(new StringReader(response.getContentAsString())));
274                         XPath xPath = XPathFactory.newInstance().newXPath();
275                         Node node = (Node) xPath.evaluate("/Device_Info/DeviceZones/text()", dDoc, XPathConstants.NODE);
276                         if (node != null) {
277                             String nodeValue = node.getNodeValue();
278                             logger.trace("/Device_Info/DeviceZones/text() = {}", nodeValue);
279                             zoneCount = Integer.parseInt(nodeValue);
280                             logger.debug("Discovered number of zones: {}", zoneCount);
281                         }
282                     } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException
283                             | NumberFormatException e) {
284                         logger.debug("Something went wrong with looking up the zone count in Deviceinfo.xml: {}",
285                                 e.getMessage());
286                     }
287                 }
288             }
289             config.setTelnet(telnetEnable);
290             config.setZoneCount(zoneCount);
291             Configuration configuration = editConfiguration();
292             configuration.put(PARAMETER_TELNET_ENABLED, telnetEnable);
293             configuration.put(PARAMETER_ZONE_COUNT, zoneCount);
294             updateConfiguration(configuration);
295         }
296     }
297
298     @Override
299     public void initialize() {
300         cancelRetry();
301         config = getConfigAs(DenonMarantzConfiguration.class);
302
303         // Configure Connection type (Telnet/HTTP) and number of zones
304         // Note: this only happens for discovered Things
305         try {
306             autoConfigure();
307         } catch (InterruptedException e) {
308             Thread.currentThread().interrupt();
309             return;
310         }
311
312         if (!checkConfiguration()) {
313             return;
314         }
315
316         denonMarantzState = new DenonMarantzState(this);
317         configureZoneChannels();
318         updateStatus(ThingStatus.UNKNOWN);
319         // create connection (either Telnet or HTTP)
320         // ThingStatus ONLINE/OFFLINE is set when AVR status is known.
321         createConnection();
322     }
323
324     private void createConnection() {
325         if (connector != null) {
326             connector.dispose();
327         }
328         connector = connectorFactory.getConnector(config, denonMarantzState, scheduler, httpClient,
329                 this.getThing().getUID().getAsString());
330         connector.connect();
331     }
332
333     private void cancelRetry() {
334         ScheduledFuture<?> localRetryJob = retryJob;
335         if (localRetryJob != null && !localRetryJob.isDone()) {
336             localRetryJob.cancel(false);
337         }
338     }
339
340     private void configureZoneChannels() {
341         logger.debug("Configuring zone channels");
342         Integer zoneCount = config.getZoneCount();
343         List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
344         boolean channelsUpdated = false;
345
346         // construct a set with the existing channel type UIDs, to quickly check
347         Set<String> currentChannels = new HashSet<>();
348         channels.forEach(channel -> currentChannels.add(channel.getUID().getId()));
349
350         Set<Entry<String, ChannelTypeUID>> channelsToRemove = new HashSet<>();
351
352         if (zoneCount > 1) {
353             List<Entry<String, ChannelTypeUID>> channelsToAdd = new ArrayList<>(ZONE2_CHANNEL_TYPES.entrySet());
354
355             if (zoneCount > 2) {
356                 // add channels for zone 3
357                 channelsToAdd.addAll(ZONE3_CHANNEL_TYPES.entrySet());
358                 if (zoneCount > 3) {
359                     // add channels for zone 4 (more zones currently not supported)
360                     channelsToAdd.addAll(ZONE4_CHANNEL_TYPES.entrySet());
361                 } else {
362                     channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
363                 }
364             } else {
365                 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
366                 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
367             }
368
369             // filter out the already existing channels
370             channelsToAdd.removeIf(c -> currentChannels.contains(c.getKey()));
371
372             // add the channels that were not yet added
373             if (!channelsToAdd.isEmpty()) {
374                 for (Entry<String, ChannelTypeUID> entry : channelsToAdd) {
375                     String itemType = CHANNEL_ITEM_TYPES.get(entry.getKey());
376                     Channel channel = ChannelBuilder
377                             .create(new ChannelUID(this.getThing().getUID(), entry.getKey()), itemType)
378                             .withType(entry.getValue()).build();
379                     channels.add(channel);
380                 }
381                 channelsUpdated = true;
382             } else {
383                 logger.debug("No zone channels have been added");
384             }
385         } else {
386             channelsToRemove.addAll(ZONE2_CHANNEL_TYPES.entrySet());
387             channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
388             channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
389         }
390
391         // filter out the non-existing channels
392         channelsToRemove.removeIf(c -> !currentChannels.contains(c.getKey()));
393
394         // remove the channels that were not yet added
395         if (!channelsToRemove.isEmpty()) {
396             for (Entry<String, ChannelTypeUID> entry : channelsToRemove) {
397                 if (channels.removeIf(c -> (entry.getKey()).equals(c.getUID().getId()))) {
398                     logger.trace("Removed channel {}", entry.getKey());
399                 } else {
400                     logger.trace("Could NOT remove channel {}", entry.getKey());
401                 }
402             }
403             channelsUpdated = true;
404         } else {
405             logger.debug("No zone channels have been removed");
406         }
407
408         // update Thing if channels changed
409         if (channelsUpdated) {
410             updateThing(editThing().withChannels(channels).build());
411         }
412     }
413
414     @Override
415     public void dispose() {
416         if (connector != null) {
417             connector.dispose();
418             connector = null;
419         }
420         cancelRetry();
421         super.dispose();
422     }
423
424     @Override
425     public void channelLinked(ChannelUID channelUID) {
426         super.channelLinked(channelUID);
427         String channelID = channelUID.getId();
428         if (isLinked(channelID)) {
429             State state = denonMarantzState.getStateForChannelID(channelID);
430             if (state != null) {
431                 updateState(channelID, state);
432             }
433         }
434     }
435
436     @Override
437     public void stateChanged(String channelID, State state) {
438         logger.debug("Received state {} for channelID {}", state, channelID);
439
440         // Don't flood the log with thing 'updated: ONLINE' each time a single channel changed
441         if (this.getThing().getStatus() != ThingStatus.ONLINE) {
442             updateStatus(ThingStatus.ONLINE);
443         }
444         updateState(channelID, state);
445     }
446
447     @Override
448     public void connectionError(String errorMessage) {
449         if (this.getThing().getStatus() != ThingStatus.OFFLINE) {
450             // Don't flood the log with thing 'updated: OFFLINE' when already offline
451             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
452         }
453         connector.dispose();
454         retryJob = scheduler.schedule(this::createConnection, RETRY_TIME_SECONDS, TimeUnit.SECONDS);
455     }
456 }