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.connector.http;
15 import java.beans.Introspector;
16 import java.io.ByteArrayInputStream;
17 import java.io.StringWriter;
18 import java.math.BigDecimal;
19 import java.net.URLEncoder;
20 import java.nio.charset.Charset;
21 import java.nio.charset.StandardCharsets;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledExecutorService;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
28 import javax.xml.bind.JAXBContext;
29 import javax.xml.bind.JAXBException;
30 import javax.xml.bind.Marshaller;
31 import javax.xml.bind.UnmarshalException;
32 import javax.xml.stream.XMLInputFactory;
33 import javax.xml.stream.XMLStreamException;
34 import javax.xml.stream.XMLStreamReader;
35 import javax.xml.stream.util.StreamReaderDelegate;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.eclipse.jetty.client.HttpClient;
40 import org.eclipse.jetty.client.api.ContentResponse;
41 import org.eclipse.jetty.client.api.Request;
42 import org.eclipse.jetty.client.api.Response;
43 import org.eclipse.jetty.client.api.Result;
44 import org.eclipse.jetty.client.util.StringContentProvider;
45 import org.eclipse.jetty.http.HttpMethod;
46 import org.eclipse.jetty.http.HttpStatus;
47 import org.openhab.binding.denonmarantz.internal.DenonMarantzState;
48 import org.openhab.binding.denonmarantz.internal.config.DenonMarantzConfiguration;
49 import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnector;
50 import org.openhab.binding.denonmarantz.internal.exception.HttpCommunicationException;
51 import org.openhab.binding.denonmarantz.internal.xml.dto.Deviceinfo;
52 import org.openhab.binding.denonmarantz.internal.xml.dto.Main;
53 import org.openhab.binding.denonmarantz.internal.xml.dto.ZoneStatus;
54 import org.openhab.binding.denonmarantz.internal.xml.dto.ZoneStatusLite;
55 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.AppCommandRequest;
56 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.AppCommandResponse;
57 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.CommandRx;
58 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.CommandTx;
59 import org.openhab.binding.denonmarantz.internal.xml.dto.types.StringType;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
64 * This class makes the connection to the receiver and manages it.
65 * It is also responsible for sending commands to the receiver.
68 * @author Jeroen Idserda - Initial Contribution (1.x Binding)
69 * @author Jan-Willem Veldhuis - Refactored for 2.x
72 public class DenonMarantzHttpConnector extends DenonMarantzConnector {
74 private Logger logger = LoggerFactory.getLogger(DenonMarantzHttpConnector.class);
76 private static final int REQUEST_TIMEOUT_MS = 5000; // 5 seconds
78 // Main URL for the receiver
79 private static final String URL_MAIN = "formMainZone_MainZoneXml.xml";
81 // Main Zone Status URL
82 private static final String URL_ZONE_MAIN = "formMainZone_MainZoneXmlStatus.xml";
84 // Secondary zone lite status URL (contains less info)
85 private static final String URL_ZONE_SECONDARY_LITE = "formZone%d_Zone%dXmlStatusLite.xml";
88 private static final String URL_DEVICE_INFO = "Deviceinfo.xml";
90 // URL to send app commands to
91 private static final String URL_APP_COMMAND = "AppCommand.xml";
93 private static final String CONTENT_TYPE_XML = "application/xml";
95 private final String cmdUrl;
97 private final String statusUrl;
99 private final HttpClient httpClient;
101 private @Nullable ScheduledFuture<?> pollingJob;
103 private boolean legacyApiSupported = true;
105 public DenonMarantzHttpConnector(DenonMarantzConfiguration config, DenonMarantzState state,
106 ScheduledExecutorService scheduler, HttpClient httpClient) {
107 super(config, scheduler, state);
108 this.cmdUrl = String.format("http://%s:%d/goform/formiPhoneAppDirect.xml?", config.getHost(),
109 config.getHttpPort());
110 this.statusUrl = String.format("http://%s:%d/goform/", config.getHost(), config.getHttpPort());
111 this.httpClient = httpClient;
114 public DenonMarantzState getState() {
119 * Set up the connection to the receiver by starting to poll the HTTP API.
122 public void connect() {
124 logger.debug("HTTP polling started.");
126 setConfigProperties();
127 } catch (TimeoutException | ExecutionException | HttpCommunicationException e) {
128 logger.debug("IO error while retrieving document:", e);
129 state.connectionError("IO error while connecting to AVR: " + e.getMessage());
131 } catch (InterruptedException e) {
132 logger.debug("Interrupted while retrieving document: {}", e.getMessage());
133 Thread.currentThread().interrupt();
136 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
138 refreshHttpProperties();
139 } catch (TimeoutException | ExecutionException e) {
140 logger.debug("IO error while retrieving document", e);
141 state.connectionError("IO error while connecting to AVR: " + e.getMessage());
143 } catch (RuntimeException e) {
145 * We need to catch this RuntimeException, as otherwise the polling stops.
146 * Log as error as it could be a user configuration error.
148 StringBuilder sb = new StringBuilder();
149 for (StackTraceElement s : e.getStackTrace()) {
150 sb.append(s.toString()).append("\n");
152 logger.error("Error while polling Http: \"{}\". Stacktrace: \n{}", e.getMessage(), sb.toString());
153 } catch (InterruptedException e) {
154 logger.debug("Interrupted while polling: {}", e.getMessage());
155 Thread.currentThread().interrupt();
157 }, 0, config.httpPollingInterval, TimeUnit.SECONDS);
161 private boolean isPolling() {
162 ScheduledFuture<?> pollingJob = this.pollingJob;
163 return pollingJob != null && !pollingJob.isCancelled();
166 private void stopPolling() {
167 ScheduledFuture<?> pollingJob = this.pollingJob;
168 if (pollingJob != null) {
169 pollingJob.cancel(true);
170 logger.debug("HTTP polling stopped.");
175 * Shutdown the http client
178 public void dispose() {
179 logger.debug("disposing connector");
185 protected void internalSendCommand(String command) {
186 logger.debug("Sending command '{}'", command);
187 if (command.isBlank()) {
188 logger.warn("Trying to send empty command");
192 String url = cmdUrl + URLEncoder.encode(command, Charset.defaultCharset());
193 logger.trace("Calling url {}", url);
195 httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).send(new Response.CompleteListener() {
197 public void onComplete(@Nullable Result result) {
198 if (result != null && result.getResponse().getStatus() != 200) {
199 logger.warn("Error {} while sending command", result.getResponse().getReason());
205 private void updateMain() throws TimeoutException, ExecutionException, InterruptedException {
206 String url = statusUrl + URL_MAIN;
207 logger.trace("Refreshing URL: {}", url);
210 Main statusMain = getDocument(url, Main.class);
211 if (statusMain != null) {
212 state.setPower(statusMain.getPower().getValue());
214 } catch (HttpCommunicationException e) {
215 if (e.getHttpStatus() == HttpStatus.FORBIDDEN_403) {
216 legacyApiSupported = false;
217 logger.debug("Legacy API not supported, will attempt app command method");
219 logger.debug("Failed to update main by legacy API: {}", e.getMessage());
224 private void updateMainZone() throws TimeoutException, ExecutionException, InterruptedException {
225 String url = statusUrl + URL_ZONE_MAIN;
226 logger.trace("Refreshing URL: {}", url);
229 ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
230 if (mainZone != null) {
231 state.setInput(mainZone.getInputFuncSelect().getValue());
232 state.setMainVolume(mainZone.getMasterVolume().getValue());
233 state.setMainZonePower(mainZone.getPower().getValue());
234 state.setMute(mainZone.getMute().getValue());
236 if (config.inputOptions == null) {
237 config.inputOptions = mainZone.getInputFuncList();
240 StringType surroundMode = mainZone.getSurrMode();
241 if (surroundMode == null) {
242 logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
244 state.setSurroundProgram(surroundMode.getValue());
247 } catch (HttpCommunicationException e) {
248 if (e.getHttpStatus() == HttpStatus.FORBIDDEN_403) {
249 legacyApiSupported = false;
250 logger.debug("Legacy API not supported, will attempt app command method");
252 logger.debug("Failed to update main zone by legacy API: {}", e.getMessage());
257 private void updateMainZoneByAppCommand() throws TimeoutException, ExecutionException, InterruptedException {
258 String url = statusUrl + URL_APP_COMMAND;
259 logger.trace("Refreshing URL: {}", url);
261 AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_ALL_POWER).add(CommandTx.CMD_VOLUME_LEVEL)
262 .add(CommandTx.CMD_MUTE_STATUS).add(CommandTx.CMD_SOURCE_STATUS).add(CommandTx.CMD_SURROUND_STATUS);
265 AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
267 if (response != null) {
268 for (CommandRx rx : response.getCommands()) {
269 String inputSource = rx.getSource();
270 if (inputSource != null) {
271 state.setInput(inputSource);
273 Boolean power = rx.getZone1();
275 state.setMainZonePower(power.booleanValue());
277 BigDecimal volume = rx.getVolume();
278 if (volume != null) {
279 state.setMainVolume(volume);
281 Boolean mute = rx.getMute();
283 state.setMute(mute.booleanValue());
285 String surroundMode = rx.getSurround();
286 if (surroundMode != null) {
287 state.setSurroundProgram(surroundMode);
291 } catch (HttpCommunicationException e) {
292 logger.debug("Failed to update main zone by app command: {}", e.getMessage());
296 private void updateSecondaryZones() throws TimeoutException, ExecutionException, InterruptedException {
297 for (int i = 2; i <= config.getZoneCount(); i++) {
298 String url = String.format("%s" + URL_ZONE_SECONDARY_LITE, statusUrl, i, i);
299 logger.trace("Refreshing URL: {}", url);
301 ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
302 if (zoneSecondary != null) {
304 // maximum 2 secondary zones are supported
306 state.setZone2Power(zoneSecondary.getPower().getValue());
307 state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
308 state.setZone2Mute(zoneSecondary.getMute().getValue());
309 state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
312 state.setZone3Power(zoneSecondary.getPower().getValue());
313 state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
314 state.setZone3Mute(zoneSecondary.getMute().getValue());
315 state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
318 state.setZone4Power(zoneSecondary.getPower().getValue());
319 state.setZone4Volume(zoneSecondary.getMasterVolume().getValue());
320 state.setZone4Mute(zoneSecondary.getMute().getValue());
321 state.setZone4Input(zoneSecondary.getInputFuncSelect().getValue());
325 } catch (HttpCommunicationException e) {
326 logger.debug("Failed to update zone {}: {}", i, e.getMessage());
331 private void updateDisplayInfo() throws TimeoutException, ExecutionException, InterruptedException {
332 String url = statusUrl + URL_APP_COMMAND;
333 logger.trace("Refreshing URL: {}", url);
335 AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
337 AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
339 if (response == null) {
342 CommandRx titleInfo = response.getCommands().get(0);
343 String artist = titleInfo.getText("artist");
344 if (artist != null) {
345 state.setNowPlayingArtist(artist);
347 String album = titleInfo.getText("album");
349 state.setNowPlayingAlbum(album);
351 String track = titleInfo.getText("track");
353 state.setNowPlayingTrack(track);
355 } catch (HttpCommunicationException e) {
356 logger.debug("Failed to update display info: {}", e.getMessage());
360 private boolean setConfigProperties()
361 throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
362 String url = statusUrl + URL_DEVICE_INFO;
363 logger.debug("Refreshing URL: {}", url);
365 Deviceinfo deviceinfo = getDocument(url, Deviceinfo.class);
366 if (deviceinfo != null) {
367 config.setZoneCount(deviceinfo.getDeviceZones());
371 * The maximum volume is received from the telnet connection in the
372 * form of the MVMAX property. It is not always received reliable however,
373 * so we're using a default for now.
375 config.setMainVolumeMax(DenonMarantzConfiguration.MAX_VOLUME);
377 // if deviceinfo is null, something went wrong (and is logged in getDocument catch blocks)
378 return (deviceinfo != null);
381 private void refreshHttpProperties() throws TimeoutException, ExecutionException, InterruptedException {
382 logger.trace("Refreshing Denon status");
384 if (legacyApiSupported) {
389 if (!legacyApiSupported) {
390 updateMainZoneByAppCommand();
393 updateSecondaryZones();
398 private <T> T getDocument(String uri, Class<T> response)
399 throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
401 Request request = httpClient.newRequest(uri).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
402 .method(HttpMethod.GET);
404 ContentResponse contentResponse = request.send();
406 String result = contentResponse.getContentAsString();
407 int status = contentResponse.getStatus();
409 logger.trace("result of getDocument for uri '{}' (status code {}):\r\n{}", uri, status, result);
411 if (!HttpStatus.isSuccess(status)) {
412 throw new HttpCommunicationException("Error retrieving document for uri '" + uri + "'", status);
415 if (result != null && !result.isBlank()) {
416 JAXBContext jc = JAXBContext.newInstance(response);
417 XMLInputFactory xif = XMLInputFactory.newInstance();
418 xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
419 xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
420 XMLStreamReader xsr = xif
421 .createXMLStreamReader(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
422 xsr = new PropertyRenamerDelegate(xsr);
424 @SuppressWarnings("unchecked")
425 T obj = (T) jc.createUnmarshaller().unmarshal(xsr);
429 } catch (UnmarshalException e) {
430 logger.debug("Failed to unmarshal xml document: {}", e.getMessage());
431 } catch (JAXBException e) {
432 logger.debug("Unexpected error occurred during unmarshalling of document: {}", e.getMessage());
433 } catch (XMLStreamException e) {
434 logger.debug("Communication error: {}", e.getMessage());
441 private <T, S> T postDocument(String uri, Class<T> response, S request)
442 throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
444 JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
445 Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
446 StringWriter sw = new StringWriter();
447 jaxbMarshaller.marshal(request, sw);
449 Request httpRequest = httpClient.newRequest(uri).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
450 .content(new StringContentProvider(sw.toString(), StandardCharsets.UTF_8), CONTENT_TYPE_XML)
451 .method(HttpMethod.POST);
453 ContentResponse contentResponse = httpRequest.send();
455 String result = contentResponse.getContentAsString();
456 int status = contentResponse.getStatus();
458 logger.trace("result of postDocument for uri '{}' (status code {}):\r\n{}", uri, status, result);
460 if (!HttpStatus.isSuccess(status)) {
461 throw new HttpCommunicationException("Error retrieving document for uri '" + uri + "'", status);
464 if (result != null && !result.isBlank()) {
465 JAXBContext jcResponse = JAXBContext.newInstance(response);
467 @SuppressWarnings("unchecked")
468 T obj = (T) jcResponse.createUnmarshaller()
469 .unmarshal(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
473 } catch (JAXBException e) {
474 logger.debug("Encoding error in post", e);
480 private static class PropertyRenamerDelegate extends StreamReaderDelegate {
482 public PropertyRenamerDelegate(XMLStreamReader xsr) {
487 public String getAttributeLocalName(int index) {
488 return Introspector.decapitalize(super.getAttributeLocalName(index)).intern();
492 public String getLocalName() {
493 return Introspector.decapitalize(super.getLocalName()).intern();