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