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.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.builder.ChannelBuilder;
57 import org.openhab.core.thing.type.ChannelTypeUID;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.openhab.core.types.State;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
63 import org.w3c.dom.Document;
64 import org.w3c.dom.Node;
65 import org.xml.sax.InputSource;
66 import org.xml.sax.SAXException;
69 * The {@link DenonMarantzHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Jan-Willem Veldhuis - Initial contribution
75 public class DenonMarantzHandler extends BaseThingHandler implements DenonMarantzStateChangedListener {
77 private final Logger logger = LoggerFactory.getLogger(DenonMarantzHandler.class);
78 private static final int RETRY_TIME_SECONDS = 30;
79 private HttpClient httpClient;
80 private @Nullable DenonMarantzConnector connector;
81 private DenonMarantzConfiguration config = new DenonMarantzConfiguration();
82 private DenonMarantzConnectorFactory connectorFactory = new DenonMarantzConnectorFactory();
83 private DenonMarantzState denonMarantzState;
84 private @Nullable ScheduledFuture<?> retryJob;
86 public DenonMarantzHandler(Thing thing, HttpClient httpClient) {
88 this.httpClient = httpClient;
89 denonMarantzState = new DenonMarantzState(this);
93 public void handleCommand(ChannelUID channelUID, Command command) {
94 DenonMarantzConnector connector = this.connector;
95 if (connector == null) {
99 if (connector instanceof DenonMarantzHttpConnector && command instanceof RefreshType) {
100 // Refreshing individual channels isn't supported by the Http connector.
101 // The connector refreshes all channels together at the configured polling interval.
106 switch (channelUID.getId()) {
108 connector.sendPowerCommand(command, 0);
110 case CHANNEL_MAIN_ZONE_POWER:
111 connector.sendPowerCommand(command, 1);
114 connector.sendMuteCommand(command, 1);
116 case CHANNEL_MAIN_VOLUME:
117 connector.sendVolumeCommand(command, 1);
119 case CHANNEL_MAIN_VOLUME_DB:
120 connector.sendVolumeDbCommand(command, 1);
123 connector.sendInputCommand(command, 1);
125 case CHANNEL_SURROUND_PROGRAM:
126 connector.sendSurroundProgramCommand(command);
128 case CHANNEL_COMMAND:
129 connector.sendCustomCommand(command);
132 case CHANNEL_ZONE2_POWER:
133 connector.sendPowerCommand(command, 2);
135 case CHANNEL_ZONE2_MUTE:
136 connector.sendMuteCommand(command, 2);
138 case CHANNEL_ZONE2_VOLUME:
139 connector.sendVolumeCommand(command, 2);
141 case CHANNEL_ZONE2_VOLUME_DB:
142 connector.sendVolumeDbCommand(command, 2);
144 case CHANNEL_ZONE2_INPUT:
145 connector.sendInputCommand(command, 2);
148 case CHANNEL_ZONE3_POWER:
149 connector.sendPowerCommand(command, 3);
151 case CHANNEL_ZONE3_MUTE:
152 connector.sendMuteCommand(command, 3);
154 case CHANNEL_ZONE3_VOLUME:
155 connector.sendVolumeCommand(command, 3);
157 case CHANNEL_ZONE3_VOLUME_DB:
158 connector.sendVolumeDbCommand(command, 3);
160 case CHANNEL_ZONE3_INPUT:
161 connector.sendInputCommand(command, 3);
164 case CHANNEL_ZONE4_POWER:
165 connector.sendPowerCommand(command, 4);
167 case CHANNEL_ZONE4_MUTE:
168 connector.sendMuteCommand(command, 4);
170 case CHANNEL_ZONE4_VOLUME:
171 connector.sendVolumeCommand(command, 4);
173 case CHANNEL_ZONE4_VOLUME_DB:
174 connector.sendVolumeDbCommand(command, 4);
176 case CHANNEL_ZONE4_INPUT:
177 connector.sendInputCommand(command, 4);
181 throw new UnsupportedCommandTypeException();
183 } catch (UnsupportedCommandTypeException e) {
184 logger.debug("Unsupported command {} for channel {}", command, channelUID.getId());
188 public boolean checkConfiguration() {
189 // prevent too low values for polling interval
190 if (config.httpPollingInterval < 5) {
191 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
192 "The polling interval should be at least 5 seconds!");
195 // Check zone count is within supported range
196 if (config.getZoneCount() < 1 || config.getZoneCount() > 4) {
197 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
198 "This binding supports 1 to 4 zones. Please update the zone count.");
205 * Try to auto configure the connection type (Telnet or HTTP) for unmanaged Things.
207 private void autoConfigure() throws InterruptedException {
209 * The isTelnet parameter has no default.
210 * When not set we will try to auto-detect the correct values
211 * for isTelnet and zoneCount and update the Thing accordingly.
213 if (config.isTelnet() == null) {
214 logger.debug("Trying to auto-detect the connection.");
215 ContentResponse response;
216 boolean telnetEnable = true;
218 boolean httpApiUsable = false;
220 // try to reach the HTTP API at port 80 (most models, except Denon ...H should respond.
221 String host = config.getHost();
223 response = httpClient.newRequest("http://" + host + "/goform/Deviceinfo.xml")
224 .timeout(3, TimeUnit.SECONDS).send();
225 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
226 logger.debug("We can access the HTTP API, disabling the Telnet mode by default.");
227 telnetEnable = false;
228 httpApiUsable = true;
230 } catch (TimeoutException | ExecutionException e) {
231 logger.debug("Error when trying to access AVR using HTTP on port 80, reverting to Telnet mode.", e);
235 // the above attempt failed. Let's try on port 8080, as for some models a subset of the HTTP API is
238 response = httpClient.newRequest("http://" + host + ":8080/goform/Deviceinfo.xml")
239 .timeout(3, TimeUnit.SECONDS).send();
240 if (response.getStatus() == HttpURLConnection.HTTP_OK) {
242 "This model responds to HTTP port 8080, we use this port to retrieve the number of zones.");
244 httpApiUsable = true;
246 } catch (TimeoutException | ExecutionException e) {
247 logger.debug("Additionally tried to connect to port 8080, this also failed", e);
251 // default zone count
254 // try to determine the zone count by checking the Deviceinfo.xml file
259 response = httpClient.newRequest("http://" + host + ":" + httpPort + "/goform/Deviceinfo.xml")
260 .timeout(3, TimeUnit.SECONDS).send();
261 status = response.getStatus();
262 } catch (TimeoutException | ExecutionException e) {
263 logger.debug("Failed in fetching the Deviceinfo.xml to determine zone count", e);
266 if (status == HttpURLConnection.HTTP_OK && response != null) {
267 DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
270 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
271 domFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
272 domFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
273 domFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
274 domFactory.setXIncludeAware(false);
275 domFactory.setExpandEntityReferences(false);
276 DocumentBuilder builder;
277 builder = domFactory.newDocumentBuilder();
278 Document dDoc = builder.parse(new InputSource(new StringReader(response.getContentAsString())));
279 XPath xPath = XPathFactory.newInstance().newXPath();
280 Node node = (Node) xPath.evaluate("/Device_Info/DeviceZones/text()", dDoc, XPathConstants.NODE);
282 String nodeValue = node.getNodeValue();
283 logger.trace("/Device_Info/DeviceZones/text() = {}", nodeValue);
284 zoneCount = Integer.parseInt(nodeValue);
285 logger.debug("Discovered number of zones: {}", zoneCount);
287 } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException
288 | NumberFormatException e) {
289 logger.debug("Something went wrong with looking up the zone count in Deviceinfo.xml: {}",
294 config.setTelnet(telnetEnable);
295 config.setZoneCount(zoneCount);
296 Configuration configuration = editConfiguration();
297 configuration.put(PARAMETER_TELNET_ENABLED, telnetEnable);
298 configuration.put(PARAMETER_ZONE_COUNT, zoneCount);
299 updateConfiguration(configuration);
304 public void initialize() {
305 config = getConfigAs(DenonMarantzConfiguration.class);
307 // Configure Connection type (Telnet/HTTP) and number of zones
308 // Note: this only happens for discovered Things
311 } catch (InterruptedException e) {
312 Thread.currentThread().interrupt();
316 if (!checkConfiguration()) {
320 configureZoneChannels();
321 updateStatus(ThingStatus.UNKNOWN);
322 // create connection (either Telnet or HTTP)
323 // ThingStatus ONLINE/OFFLINE is set when AVR status is known.
327 private void createConnection() {
328 DenonMarantzConnector connector = this.connector;
329 if (connector != null) {
332 this.connector = connector = connectorFactory.getConnector(config, denonMarantzState, scheduler, httpClient,
333 this.getThing().getUID().getAsString());
337 private void cancelRetry() {
338 ScheduledFuture<?> retryJob = this.retryJob;
339 if (retryJob != null) {
340 retryJob.cancel(true);
342 this.retryJob = null;
345 private void configureZoneChannels() {
346 logger.debug("Configuring zone channels");
347 Integer zoneCount = config.getZoneCount();
348 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
349 boolean channelsUpdated = false;
351 // construct a set with the existing channel type UIDs, to quickly check
352 Set<String> currentChannels = new HashSet<>();
353 channels.forEach(channel -> currentChannels.add(channel.getUID().getId()));
355 Set<Entry<String, ChannelTypeUID>> channelsToRemove = new HashSet<>();
358 List<Entry<String, ChannelTypeUID>> channelsToAdd = new ArrayList<>(ZONE2_CHANNEL_TYPES.entrySet());
361 // add channels for zone 3
362 channelsToAdd.addAll(ZONE3_CHANNEL_TYPES.entrySet());
364 // add channels for zone 4 (more zones currently not supported)
365 channelsToAdd.addAll(ZONE4_CHANNEL_TYPES.entrySet());
367 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
370 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
371 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
374 // filter out the already existing channels
375 channelsToAdd.removeIf(c -> currentChannels.contains(c.getKey()));
377 // add the channels that were not yet added
378 if (!channelsToAdd.isEmpty()) {
379 for (Entry<String, ChannelTypeUID> entry : channelsToAdd) {
380 String itemType = CHANNEL_ITEM_TYPES.get(entry.getKey());
381 Channel channel = ChannelBuilder
382 .create(new ChannelUID(this.getThing().getUID(), entry.getKey()), itemType)
383 .withType(entry.getValue()).build();
384 channels.add(channel);
386 channelsUpdated = true;
388 logger.debug("No zone channels have been added");
391 channelsToRemove.addAll(ZONE2_CHANNEL_TYPES.entrySet());
392 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
393 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
396 // filter out the non-existing channels
397 channelsToRemove.removeIf(c -> !currentChannels.contains(c.getKey()));
399 // remove the channels that were not yet added
400 if (!channelsToRemove.isEmpty()) {
401 for (Entry<String, ChannelTypeUID> entry : channelsToRemove) {
402 if (channels.removeIf(c -> (entry.getKey()).equals(c.getUID().getId()))) {
403 logger.trace("Removed channel {}", entry.getKey());
405 logger.trace("Could NOT remove channel {}", entry.getKey());
408 channelsUpdated = true;
410 logger.debug("No zone channels have been removed");
413 // update Thing if channels changed
414 if (channelsUpdated) {
415 updateThing(editThing().withChannels(channels).build());
420 public void dispose() {
421 DenonMarantzConnector connector = this.connector;
422 if (connector != null) {
425 this.connector = null;
431 public void channelLinked(ChannelUID channelUID) {
432 super.channelLinked(channelUID);
433 String channelID = channelUID.getId();
434 if (isLinked(channelID)) {
435 State state = denonMarantzState.getStateForChannelID(channelID);
437 updateState(channelID, state);
443 public void stateChanged(String channelID, State state) {
444 logger.debug("Received state {} for channelID {}", state, channelID);
446 // Don't flood the log with thing 'updated: ONLINE' each time a single channel changed
447 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
448 updateStatus(ThingStatus.ONLINE);
450 updateState(channelID, state);
454 public void connectionError(String errorMessage) {
455 if (this.getThing().getStatus() != ThingStatus.OFFLINE) {
456 // Don't flood the log with thing 'updated: OFFLINE' when already offline
457 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
460 DenonMarantzConnector connector = this.connector;
461 if (connector != null) {
464 this.connector = null;
466 retryJob = scheduler.schedule(this::createConnection, RETRY_TIME_SECONDS, TimeUnit.SECONDS);