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.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;
70 * The {@link DenonMarantzHandler} is responsible for handling commands, which are
71 * sent to one of the channels.
73 * @author Jan-Willem Veldhuis - Initial contribution
76 public class DenonMarantzHandler extends BaseThingHandler implements DenonMarantzStateChangedListener {
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;
87 public DenonMarantzHandler(Thing thing, HttpClient httpClient) {
89 this.httpClient = httpClient;
90 denonMarantzState = new DenonMarantzState(this);
94 public void handleCommand(ChannelUID channelUID, Command command) {
95 DenonMarantzConnector connector = this.connector;
96 if (connector == null) {
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.
107 switch (channelUID.getId()) {
109 connector.sendPowerCommand(command, 0);
111 case CHANNEL_MAIN_ZONE_POWER:
112 connector.sendPowerCommand(command, 1);
115 connector.sendMuteCommand(command, 1);
117 case CHANNEL_MAIN_VOLUME:
118 connector.sendVolumeCommand(command, 1);
120 case CHANNEL_MAIN_VOLUME_DB:
121 connector.sendVolumeDbCommand(command, 1);
124 connector.sendInputCommand(command, 1);
126 case CHANNEL_SURROUND_PROGRAM:
127 connector.sendSurroundProgramCommand(command);
129 case CHANNEL_COMMAND:
130 connector.sendCustomCommand(command);
133 case CHANNEL_ZONE2_POWER:
134 connector.sendPowerCommand(command, 2);
136 case CHANNEL_ZONE2_MUTE:
137 connector.sendMuteCommand(command, 2);
139 case CHANNEL_ZONE2_VOLUME:
140 connector.sendVolumeCommand(command, 2);
142 case CHANNEL_ZONE2_VOLUME_DB:
143 connector.sendVolumeDbCommand(command, 2);
145 case CHANNEL_ZONE2_INPUT:
146 connector.sendInputCommand(command, 2);
149 case CHANNEL_ZONE3_POWER:
150 connector.sendPowerCommand(command, 3);
152 case CHANNEL_ZONE3_MUTE:
153 connector.sendMuteCommand(command, 3);
155 case CHANNEL_ZONE3_VOLUME:
156 connector.sendVolumeCommand(command, 3);
158 case CHANNEL_ZONE3_VOLUME_DB:
159 connector.sendVolumeDbCommand(command, 3);
161 case CHANNEL_ZONE3_INPUT:
162 connector.sendInputCommand(command, 3);
165 case CHANNEL_ZONE4_POWER:
166 connector.sendPowerCommand(command, 4);
168 case CHANNEL_ZONE4_MUTE:
169 connector.sendMuteCommand(command, 4);
171 case CHANNEL_ZONE4_VOLUME:
172 connector.sendVolumeCommand(command, 4);
174 case CHANNEL_ZONE4_VOLUME_DB:
175 connector.sendVolumeDbCommand(command, 4);
177 case CHANNEL_ZONE4_INPUT:
178 connector.sendInputCommand(command, 4);
182 throw new UnsupportedCommandTypeException();
184 } catch (UnsupportedCommandTypeException e) {
185 logger.debug("Unsupported command {} for channel {}", command, channelUID.getId());
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!");
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.");
206 * Try to auto configure the connection type (Telnet or HTTP) for unmanaged Things.
208 private void autoConfigure() throws InterruptedException {
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.
214 if (config.isTelnet() == null) {
215 logger.debug("Trying to auto-detect the connection.");
216 ContentResponse response;
217 boolean telnetEnable = true;
219 boolean httpApiUsable = false;
221 // try to reach the HTTP API at port 80 (most models, except Denon ...H should respond.
222 String host = config.getHost();
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;
231 } catch (TimeoutException | ExecutionException e) {
232 logger.debug("Error when trying to access AVR using HTTP on port 80.", e);
236 // the above attempt failed. Let's try on port 8080, as for some models a subset of the HTTP API is
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("This model responds to HTTP port 8080, disabling the Telnet mode by default.");
243 telnetEnable = false;
245 httpApiUsable = true;
247 } catch (TimeoutException | ExecutionException e) {
249 "Additionally tried to connect to port 8080, this also failed. Reverting to Telnet mode.",
254 // default zone count
257 // try to determine the zone count by checking the Deviceinfo.xml file
262 response = httpClient.newRequest("http://" + host + ":" + httpPort + "/goform/Deviceinfo.xml")
263 .timeout(3, TimeUnit.SECONDS).send();
264 status = response.getStatus();
265 } catch (TimeoutException | ExecutionException e) {
266 logger.debug("Failed in fetching the Deviceinfo.xml to determine zone count", e);
269 if (status == HttpURLConnection.HTTP_OK && response != null) {
270 DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();
273 // https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
274 domFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
275 domFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
276 domFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
277 domFactory.setXIncludeAware(false);
278 domFactory.setExpandEntityReferences(false);
279 DocumentBuilder builder;
280 builder = domFactory.newDocumentBuilder();
281 Document dDoc = builder.parse(new InputSource(new StringReader(response.getContentAsString())));
282 XPath xPath = XPathFactory.newInstance().newXPath();
283 Node node = (Node) xPath.evaluate("/Device_Info/DeviceZones/text()", dDoc, XPathConstants.NODE);
285 String nodeValue = node.getNodeValue();
286 logger.trace("/Device_Info/DeviceZones/text() = {}", nodeValue);
287 zoneCount = Integer.parseInt(nodeValue);
288 logger.debug("Discovered number of zones: {}", zoneCount);
290 } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException
291 | NumberFormatException e) {
292 logger.debug("Something went wrong with looking up the zone count in Deviceinfo.xml: {}",
297 config.setTelnet(telnetEnable);
298 config.setZoneCount(zoneCount);
299 Configuration configuration = editConfiguration();
300 configuration.put(PARAMETER_TELNET_ENABLED, telnetEnable);
301 configuration.put(PARAMETER_ZONE_COUNT, zoneCount);
302 updateConfiguration(configuration);
307 public void initialize() {
308 config = getConfigAs(DenonMarantzConfiguration.class);
310 // Configure Connection type (Telnet/HTTP) and number of zones
311 // Note: this only happens for discovered Things
314 } catch (InterruptedException e) {
315 Thread.currentThread().interrupt();
319 if (!checkConfiguration()) {
323 configureZoneChannels();
324 updateStatus(ThingStatus.UNKNOWN);
325 // create connection (either Telnet or HTTP)
326 // ThingStatus ONLINE/OFFLINE is set when AVR status is known.
330 private void createConnection() {
331 DenonMarantzConnector connector = this.connector;
332 if (connector != null) {
335 this.connector = connector = connectorFactory.getConnector(config, denonMarantzState, scheduler, httpClient,
336 this.getThing().getUID().getAsString());
340 private void cancelRetry() {
341 ScheduledFuture<?> retryJob = this.retryJob;
342 if (retryJob != null) {
343 retryJob.cancel(true);
345 this.retryJob = null;
348 private void configureZoneChannels() {
349 logger.debug("Configuring zone channels");
350 Integer zoneCount = config.getZoneCount();
351 List<Channel> channels = new ArrayList<>(this.getThing().getChannels());
352 boolean channelsUpdated = false;
354 // construct a set with the existing channel type UIDs, to quickly check
355 Set<String> currentChannels = new HashSet<>();
356 channels.forEach(channel -> currentChannels.add(channel.getUID().getId()));
358 Set<Entry<String, ChannelTypeUID>> channelsToRemove = new HashSet<>();
361 List<Entry<String, ChannelTypeUID>> channelsToAdd = new ArrayList<>(ZONE2_CHANNEL_TYPES.entrySet());
364 // add channels for zone 3
365 channelsToAdd.addAll(ZONE3_CHANNEL_TYPES.entrySet());
367 // add channels for zone 4 (more zones currently not supported)
368 channelsToAdd.addAll(ZONE4_CHANNEL_TYPES.entrySet());
370 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
373 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
374 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
377 // filter out the already existing channels
378 channelsToAdd.removeIf(c -> currentChannels.contains(c.getKey()));
380 // add the channels that were not yet added
381 if (!channelsToAdd.isEmpty()) {
382 ThingHandlerCallback callback = getCallback();
383 if (callback != null) {
384 for (Entry<String, ChannelTypeUID> entry : channelsToAdd) {
385 ChannelUID channelUID = new ChannelUID(this.getThing().getUID(), entry.getKey());
386 channels.add(callback.createChannelBuilder(channelUID, entry.getValue())
387 .withKind(ChannelKind.STATE).build());
389 channelsUpdated = true;
391 logger.warn("Could not create zone channels");
394 logger.debug("No zone channels have been added");
397 channelsToRemove.addAll(ZONE2_CHANNEL_TYPES.entrySet());
398 channelsToRemove.addAll(ZONE3_CHANNEL_TYPES.entrySet());
399 channelsToRemove.addAll(ZONE4_CHANNEL_TYPES.entrySet());
402 // filter out the non-existing channels
403 channelsToRemove.removeIf(c -> !currentChannels.contains(c.getKey()));
405 // remove the channels that were not yet added
406 if (!channelsToRemove.isEmpty()) {
407 for (Entry<String, ChannelTypeUID> entry : channelsToRemove) {
408 if (channels.removeIf(c -> (entry.getKey()).equals(c.getUID().getId()))) {
409 logger.trace("Removed channel {}", entry.getKey());
411 logger.trace("Could NOT remove channel {}", entry.getKey());
414 channelsUpdated = true;
416 logger.debug("No zone channels have been removed");
419 // update Thing if channels changed
420 if (channelsUpdated) {
421 updateThing(editThing().withChannels(channels).build());
426 public void dispose() {
427 DenonMarantzConnector connector = this.connector;
428 if (connector != null) {
431 this.connector = null;
437 public void channelLinked(ChannelUID channelUID) {
438 super.channelLinked(channelUID);
439 String channelID = channelUID.getId();
440 if (isLinked(channelID)) {
441 State state = denonMarantzState.getStateForChannelID(channelID);
443 updateState(channelID, state);
449 public void stateChanged(String channelID, State state) {
450 logger.debug("Received state {} for channelID {}", state, channelID);
452 // Don't flood the log with thing 'updated: ONLINE' each time a single channel changed
453 if (this.getThing().getStatus() != ThingStatus.ONLINE) {
454 updateStatus(ThingStatus.ONLINE);
456 updateState(channelID, state);
460 public void connectionError(String errorMessage) {
461 if (this.getThing().getStatus() != ThingStatus.OFFLINE) {
462 // Don't flood the log with thing 'updated: OFFLINE' when already offline
463 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
466 DenonMarantzConnector connector = this.connector;
467 if (connector != null) {
470 this.connector = null;
472 retryJob = scheduler.schedule(this::createConnection, RETRY_TIME_SECONDS, TimeUnit.SECONDS);