2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.denonmarantz.internal.handler;
15 import static org.openhab.binding.denonmarantz.internal.DenonMarantzBindingConstants.*;
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;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
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;
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;
67 * The {@link DenonMarantzHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * @author Jan-Willem Veldhuis - Initial contribution
72 public class DenonMarantzHandler extends BaseThingHandler implements DenonMarantzStateChangedListener {
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;
83 public DenonMarantzHandler(Thing thing, HttpClient httpClient) {
85 this.httpClient = httpClient;
89 public void handleCommand(ChannelUID channelUID, Command command) {
90 if (connector == null) {
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.
101 switch (channelUID.getId()) {
103 connector.sendPowerCommand(command, 0);
105 case CHANNEL_MAIN_ZONE_POWER:
106 connector.sendPowerCommand(command, 1);
109 connector.sendMuteCommand(command, 1);
111 case CHANNEL_MAIN_VOLUME:
112 connector.sendVolumeCommand(command, 1);
114 case CHANNEL_MAIN_VOLUME_DB:
115 connector.sendVolumeDbCommand(command, 1);
118 connector.sendInputCommand(command, 1);
120 case CHANNEL_SURROUND_PROGRAM:
121 connector.sendSurroundProgramCommand(command);
123 case CHANNEL_COMMAND:
124 connector.sendCustomCommand(command);
127 case CHANNEL_ZONE2_POWER:
128 connector.sendPowerCommand(command, 2);
130 case CHANNEL_ZONE2_MUTE:
131 connector.sendMuteCommand(command, 2);
133 case CHANNEL_ZONE2_VOLUME:
134 connector.sendVolumeCommand(command, 2);
136 case CHANNEL_ZONE2_VOLUME_DB:
137 connector.sendVolumeDbCommand(command, 2);
139 case CHANNEL_ZONE2_INPUT:
140 connector.sendInputCommand(command, 2);
143 case CHANNEL_ZONE3_POWER:
144 connector.sendPowerCommand(command, 3);
146 case CHANNEL_ZONE3_MUTE:
147 connector.sendMuteCommand(command, 3);
149 case CHANNEL_ZONE3_VOLUME:
150 connector.sendVolumeCommand(command, 3);
152 case CHANNEL_ZONE3_VOLUME_DB:
153 connector.sendVolumeDbCommand(command, 3);
155 case CHANNEL_ZONE3_INPUT:
156 connector.sendInputCommand(command, 3);
159 case CHANNEL_ZONE4_POWER:
160 connector.sendPowerCommand(command, 4);
162 case CHANNEL_ZONE4_MUTE:
163 connector.sendMuteCommand(command, 4);
165 case CHANNEL_ZONE4_VOLUME:
166 connector.sendVolumeCommand(command, 4);
168 case CHANNEL_ZONE4_VOLUME_DB:
169 connector.sendVolumeDbCommand(command, 4);
171 case CHANNEL_ZONE4_INPUT:
172 connector.sendInputCommand(command, 4);
176 throw new UnsupportedCommandTypeException();
178 } catch (UnsupportedCommandTypeException e) {
179 logger.debug("Unsupported command {} for channel {}", command, channelUID.getId());
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!");
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.");
200 * Try to auto configure the connection type (Telnet or HTTP)
201 * for Things not added through Paper UI.
203 private void autoConfigure() {
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.
209 if (config.isTelnet() == null) {
210 logger.debug("Trying to auto-detect the connection.");
211 ContentResponse response;
212 boolean telnetEnable = true;
214 boolean httpApiUsable = false;
216 // try to reach the HTTP API at port 80 (most models, except Denon ...H should respond.
217 String host = config.getHost();
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;
226 } catch (InterruptedException | TimeoutException | ExecutionException e) {
227 logger.debug("Error when trying to access AVR using HTTP on port 80, reverting to Telnet mode.", e);
231 // the above attempt failed. Let's try on port 8080, as for some models a subset of the HTTP API is
234 response = httpClient.newRequest("http://" + host + ":8080/goform/Deviceinfo.xml")
235 .timeout(3, TimeUnit.SECONDS).send();
236 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
238 "This model responds to HTTP port 8080, we use this port to retrieve the number of zones.");
240 httpApiUsable = true;
242 } catch (InterruptedException | TimeoutException | ExecutionException e) {
243 logger.debug("Additionally tried to connect to port 8080, this also failed", e);
247 // default zone count
250 // try to determine the zone count by checking the Deviceinfo.xml file
255 response = httpClient.newRequest("http://" + host + ":" + httpPort + "/goform/Deviceinfo.xml")
256 .timeout(3, TimeUnit.SECONDS).send();
257 status = response.getStatus();
258 } catch (InterruptedException | TimeoutException | ExecutionException e) {
259 logger.debug("Failed in fetching the Deviceinfo.xml to determine zone count", e);
262 if (status == HttpURLConnection.HTTP_OK && response != null) {
263 DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
264 DocumentBuilder builder;
266 builder = domFactory.newDocumentBuilder();
267 Document dDoc = builder.parse(new InputSource(new StringReader(response.getContentAsString())));
268 XPath xPath = XPathFactory.newInstance().newXPath();
269 Node node = (Node) xPath.evaluate("/Device_Info/DeviceZones/text()", dDoc, XPathConstants.NODE);
271 String nodeValue = node.getNodeValue();
272 logger.trace("/Device_Info/DeviceZones/text() = {}", nodeValue);
273 zoneCount = Integer.parseInt(nodeValue);
274 logger.debug("Discovered number of zones: {}", zoneCount);
276 } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException
277 | NumberFormatException e) {
278 logger.debug("Something went wrong with looking up the zone count in Deviceinfo.xml: {}",
283 config.setTelnet(telnetEnable);
284 config.setZoneCount(zoneCount);
285 Configuration configuration = editConfiguration();
286 configuration.put(PARAMETER_TELNET_ENABLED, telnetEnable);
287 configuration.put(PARAMETER_ZONE_COUNT, zoneCount);
288 updateConfiguration(configuration);
293 public void initialize() {
295 config = getConfigAs(DenonMarantzConfiguration.class);
297 // Configure Connection type (Telnet/HTTP) and number of zones
298 // Note: this only happens for discovered Things
301 if (!checkConfiguration()) {
305 denonMarantzState = new DenonMarantzState(this);
306 configureZoneChannels();
307 updateStatus(ThingStatus.UNKNOWN);
308 // create connection (either Telnet or HTTP)
309 // ThingStatus ONLINE/OFFLINE is set when AVR status is known.
313 private void createConnection() {
314 if (connector != null) {
317 connector = connectorFactory.getConnector(config, denonMarantzState, scheduler, httpClient);
321 private void cancelRetry() {
322 ScheduledFuture<?> localRetryJob = retryJob;
323 if (localRetryJob != null && !localRetryJob.isDone()) {
324 localRetryJob.cancel(false);
328 private void configureZoneChannels() {
329 logger.debug("Configuring zone channels");
330 Integer zoneCount = config.getZoneCount();
331 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
332 boolean channelsUpdated = false;
334 // construct a set with the existing channel type UIDs, to quickly check
335 Set<String> currentChannels = new HashSet<>();
336 channels.forEach(channel -> currentChannels.add(channel.getUID().getId()));
338 Set<Entry<String, ChannelTypeUID>> channelsToRemove = new HashSet<>();
341 List<Entry<String, ChannelTypeUID>> channelsToAdd = new ArrayList<>(ZONE2_CHANNEL_TYPES.entrySet());
344 // add channels for zone 3
345 channelsToAdd.addAll(ZONE3_CHANNEL_TYPES.entrySet());
347 // add channels for zone 4 (more zones currently not supported)
348 channelsToAdd.addAll(ZONE4_CHANNEL_TYPES.entrySet());
350 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
353 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
354 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
357 // filter out the already existing channels
358 channelsToAdd.removeIf(c -> currentChannels.contains(c.getKey()));
360 // add the channels that were not yet added
361 if (!channelsToAdd.isEmpty()) {
362 for (Entry<String, ChannelTypeUID> entry : channelsToAdd) {
363 String itemType = CHANNEL_ITEM_TYPES.get(entry.getKey());
364 Channel channel = ChannelBuilder
365 .create(new ChannelUID(this.getThing().getUID(), entry.getKey()), itemType)
366 .withType(entry.getValue()).build();
367 channels.add(channel);
369 channelsUpdated = true;
371 logger.debug("No zone channels have been added");
374 channelsToRemove.addAll(ZONE2_CHANNEL_TYPES.entrySet());
375 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
376 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
379 // filter out the non-existing channels
380 channelsToRemove.removeIf(c -> !currentChannels.contains(c.getKey()));
382 // remove the channels that were not yet added
383 if (!channelsToRemove.isEmpty()) {
384 for (Entry<String, ChannelTypeUID> entry : channelsToRemove) {
385 if (channels.removeIf(c -> (entry.getKey()).equals(c.getUID().getId()))) {
386 logger.trace("Removed channel {}", entry.getKey());
388 logger.trace("Could NOT remove channel {}", entry.getKey());
391 channelsUpdated = true;
393 logger.debug("No zone channels have been removed");
396 // update Thing if channels changed
397 if (channelsUpdated) {
398 updateThing(editThing().withChannels(channels).build());
403 public void dispose() {
404 if (connector != null) {
413 public void channelLinked(ChannelUID channelUID) {
414 super.channelLinked(channelUID);
415 String channelID = channelUID.getId();
416 if (isLinked(channelID)) {
417 State state = denonMarantzState.getStateForChannelID(channelID);
419 updateState(channelID, state);
425 public void stateChanged(String channelID, State state) {
426 logger.debug("Received state {} for channelID {}", state, channelID);
428 // Don't flood the log with thing 'updated: ONLINE' each time a single channel changed
429 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
430 updateStatus(ThingStatus.ONLINE);
432 updateState(channelID, state);
436 public void connectionError(String errorMessage) {
437 if (this.getThing().getStatus() != ThingStatus.OFFLINE) {
438 // Don't flood the log with thing 'updated: OFFLINE' when already offline
439 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
442 retryJob = scheduler.schedule(this::createConnection, RETRY_TIME_SECONDS, TimeUnit.SECONDS);