2 * Copyright (c) 2010-2024 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) for unmanaged Things.
202 private void autoConfigure() throws InterruptedException {
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.
208 if (config.isTelnet() == null) {
209 logger.debug("Trying to auto-detect the connection.");
210 ContentResponse response;
211 boolean telnetEnable = true;
213 boolean httpApiUsable = false;
215 // try to reach the HTTP API at port 80 (most models, except Denon ...H should respond.
216 String host = config.getHost();
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;
225 } catch (TimeoutException | ExecutionException e) {
226 logger.debug("Error when trying to access AVR using HTTP on port 80, reverting to Telnet mode.", e);
230 // the above attempt failed. Let's try on port 8080, as for some models a subset of the HTTP API is
233 response = httpClient.newRequest("http://" + host + ":8080/goform/Deviceinfo.xml")
234 .timeout(3, TimeUnit.SECONDS).send();
235 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
237 "This model responds to HTTP port 8080, we use this port to retrieve the number of zones.");
239 httpApiUsable = true;
241 } catch (TimeoutException | ExecutionException e) {
242 logger.debug("Additionally tried to connect to port 8080, this also failed", e);
246 // default zone count
249 // try to determine the zone count by checking the Deviceinfo.xml file
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);
261 if (status == HttpURLConnection.HTTP_OK && response != null) {
262 DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
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);
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);
282 } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException
283 | NumberFormatException e) {
284 logger.debug("Something went wrong with looking up the zone count in Deviceinfo.xml: {}",
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);
299 public void initialize() {
301 config = getConfigAs(DenonMarantzConfiguration.class);
303 // Configure Connection type (Telnet/HTTP) and number of zones
304 // Note: this only happens for discovered Things
307 } catch (InterruptedException e) {
308 Thread.currentThread().interrupt();
312 if (!checkConfiguration()) {
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.
324 private void createConnection() {
325 if (connector != null) {
328 connector = connectorFactory.getConnector(config, denonMarantzState, scheduler, httpClient,
329 this.getThing().getUID().getAsString());
333 private void cancelRetry() {
334 ScheduledFuture<?> localRetryJob = retryJob;
335 if (localRetryJob != null && !localRetryJob.isDone()) {
336 localRetryJob.cancel(false);
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;
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()));
350 Set<Entry<String, ChannelTypeUID>> channelsToRemove = new HashSet<>();
353 List<Entry<String, ChannelTypeUID>> channelsToAdd = new ArrayList<>(ZONE2_CHANNEL_TYPES.entrySet());
356 // add channels for zone 3
357 channelsToAdd.addAll(ZONE3_CHANNEL_TYPES.entrySet());
359 // add channels for zone 4 (more zones currently not supported)
360 channelsToAdd.addAll(ZONE4_CHANNEL_TYPES.entrySet());
362 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
365 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
366 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
369 // filter out the already existing channels
370 channelsToAdd.removeIf(c -> currentChannels.contains(c.getKey()));
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);
381 channelsUpdated = true;
383 logger.debug("No zone channels have been added");
386 channelsToRemove.addAll(ZONE2_CHANNEL_TYPES.entrySet());
387 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
388 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
391 // filter out the non-existing channels
392 channelsToRemove.removeIf(c -> !currentChannels.contains(c.getKey()));
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());
400 logger.trace("Could NOT remove channel {}", entry.getKey());
403 channelsUpdated = true;
405 logger.debug("No zone channels have been removed");
408 // update Thing if channels changed
409 if (channelsUpdated) {
410 updateThing(editThing().withChannels(channels).build());
415 public void dispose() {
416 if (connector != null) {
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);
431 updateState(channelID, state);
437 public void stateChanged(String channelID, State state) {
438 logger.debug("Received state {} for channelID {}", state, channelID);
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);
444 updateState(channelID, state);
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);
454 retryJob = scheduler.schedule(this::createConnection, RETRY_TIME_SECONDS, TimeUnit.SECONDS);