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.IOException;
18 import java.io.StringWriter;
19 import java.net.URLEncoder;
20 import java.nio.charset.Charset;
21 import java.nio.charset.StandardCharsets;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
26 import javax.xml.bind.JAXBContext;
27 import javax.xml.bind.JAXBException;
28 import javax.xml.bind.Marshaller;
29 import javax.xml.bind.UnmarshalException;
30 import javax.xml.stream.XMLInputFactory;
31 import javax.xml.stream.XMLStreamException;
32 import javax.xml.stream.XMLStreamReader;
33 import javax.xml.stream.util.StreamReaderDelegate;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.eclipse.jetty.client.HttpClient;
38 import org.eclipse.jetty.client.api.Response;
39 import org.eclipse.jetty.client.api.Result;
40 import org.openhab.binding.denonmarantz.internal.DenonMarantzState;
41 import org.openhab.binding.denonmarantz.internal.config.DenonMarantzConfiguration;
42 import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnector;
43 import org.openhab.binding.denonmarantz.internal.xml.dto.Deviceinfo;
44 import org.openhab.binding.denonmarantz.internal.xml.dto.Main;
45 import org.openhab.binding.denonmarantz.internal.xml.dto.ZoneStatus;
46 import org.openhab.binding.denonmarantz.internal.xml.dto.ZoneStatusLite;
47 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.AppCommandRequest;
48 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.AppCommandResponse;
49 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.CommandRx;
50 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.CommandTx;
51 import org.openhab.core.io.net.http.HttpUtil;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
56 * This class makes the connection to the receiver and manages it.
57 * It is also responsible for sending commands to the receiver.
60 * @author Jeroen Idserda - Initial Contribution (1.x Binding)
61 * @author Jan-Willem Veldhuis - Refactored for 2.x
64 public class DenonMarantzHttpConnector extends DenonMarantzConnector {
66 private Logger logger = LoggerFactory.getLogger(DenonMarantzHttpConnector.class);
68 private static final int REQUEST_TIMEOUT_MS = 5000; // 5 seconds
70 // Main URL for the receiver
71 private static final String URL_MAIN = "formMainZone_MainZoneXml.xml";
73 // Main Zone Status URL
74 private static final String URL_ZONE_MAIN = "formMainZone_MainZoneXmlStatus.xml";
76 // Secondary zone lite status URL (contains less info)
77 private static final String URL_ZONE_SECONDARY_LITE = "formZone%d_Zone%dXmlStatusLite.xml";
80 private static final String URL_DEVICE_INFO = "Deviceinfo.xml";
82 // URL to send app commands to
83 private static final String URL_APP_COMMAND = "AppCommand.xml";
85 private static final String CONTENT_TYPE_XML = "application/xml";
87 private final String cmdUrl;
89 private final String statusUrl;
91 private final HttpClient httpClient;
93 private @Nullable ScheduledFuture<?> pollingJob;
95 public DenonMarantzHttpConnector(DenonMarantzConfiguration config, DenonMarantzState state,
96 ScheduledExecutorService scheduler, HttpClient httpClient) {
97 super(config, scheduler, state);
98 this.cmdUrl = String.format("http://%s:%d/goform/formiPhoneAppDirect.xml?", config.getHost(),
99 config.getHttpPort());
100 this.statusUrl = String.format("http://%s:%d/goform/", config.getHost(), config.getHttpPort());
101 this.httpClient = httpClient;
104 public DenonMarantzState getState() {
109 * Set up the connection to the receiver by starting to poll the HTTP API.
112 public void connect() {
114 logger.debug("HTTP polling started.");
116 setConfigProperties();
117 } catch (IOException e) {
118 logger.debug("IO error while retrieving document:", e);
119 state.connectionError("IO error while connecting to AVR: " + e.getMessage());
123 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
125 refreshHttpProperties();
126 } catch (IOException e) {
127 logger.debug("IO error while retrieving document", e);
128 state.connectionError("IO error while connecting to AVR: " + e.getMessage());
130 } catch (RuntimeException e) {
132 * We need to catch this RuntimeException, as otherwise the polling stops.
133 * Log as error as it could be a user configuration error.
135 StringBuilder sb = new StringBuilder();
136 for (StackTraceElement s : e.getStackTrace()) {
137 sb.append(s.toString()).append("\n");
139 logger.error("Error while polling Http: \"{}\". Stacktrace: \n{}", e.getMessage(), sb.toString());
141 }, 0, config.httpPollingInterval, TimeUnit.SECONDS);
145 private boolean isPolling() {
146 ScheduledFuture<?> pollingJob = this.pollingJob;
147 return pollingJob != null && !pollingJob.isCancelled();
150 private void stopPolling() {
151 ScheduledFuture<?> pollingJob = this.pollingJob;
152 if (pollingJob != null) {
153 pollingJob.cancel(true);
154 logger.debug("HTTP polling stopped.");
159 * Shutdown the http client
162 public void dispose() {
163 logger.debug("disposing connector");
169 protected void internalSendCommand(String command) {
170 logger.debug("Sending command '{}'", command);
171 if (command.isBlank()) {
172 logger.warn("Trying to send empty command");
176 String url = cmdUrl + URLEncoder.encode(command, Charset.defaultCharset());
177 logger.trace("Calling url {}", url);
179 httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).send(new Response.CompleteListener() {
181 public void onComplete(@Nullable Result result) {
182 if (result != null && result.getResponse().getStatus() != 200) {
183 logger.warn("Error {} while sending command", result.getResponse().getReason());
189 private void updateMain() throws IOException {
190 String url = statusUrl + URL_MAIN;
191 logger.trace("Refreshing URL: {}", url);
193 Main statusMain = getDocument(url, Main.class);
194 if (statusMain != null) {
195 state.setPower(statusMain.getPower().getValue());
199 private void updateMainZone() throws IOException {
200 String url = statusUrl + URL_ZONE_MAIN;
201 logger.trace("Refreshing URL: {}", url);
203 ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
204 if (mainZone != null) {
205 state.setInput(mainZone.getInputFuncSelect().getValue());
206 state.setMainVolume(mainZone.getMasterVolume().getValue());
207 state.setMainZonePower(mainZone.getPower().getValue());
208 state.setMute(mainZone.getMute().getValue());
210 if (config.inputOptions == null) {
211 config.inputOptions = mainZone.getInputFuncList();
214 if (mainZone.getSurrMode() == null) {
215 logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
217 state.setSurroundProgram(mainZone.getSurrMode().getValue());
222 private void updateSecondaryZones() throws IOException {
223 for (int i = 2; i <= config.getZoneCount(); i++) {
224 String url = String.format("%s" + URL_ZONE_SECONDARY_LITE, statusUrl, i, i);
225 logger.trace("Refreshing URL: {}", url);
226 ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
227 if (zoneSecondary != null) {
229 // maximum 2 secondary zones are supported
231 state.setZone2Power(zoneSecondary.getPower().getValue());
232 state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
233 state.setZone2Mute(zoneSecondary.getMute().getValue());
234 state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
237 state.setZone3Power(zoneSecondary.getPower().getValue());
238 state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
239 state.setZone3Mute(zoneSecondary.getMute().getValue());
240 state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
243 state.setZone4Power(zoneSecondary.getPower().getValue());
244 state.setZone4Volume(zoneSecondary.getMasterVolume().getValue());
245 state.setZone4Mute(zoneSecondary.getMute().getValue());
246 state.setZone4Input(zoneSecondary.getInputFuncSelect().getValue());
253 private void updateDisplayInfo() throws IOException {
254 String url = statusUrl + URL_APP_COMMAND;
255 logger.trace("Refreshing URL: {}", url);
257 AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
258 AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
260 if (response == null) {
263 CommandRx titleInfo = response.getCommands().get(0);
264 String artist = titleInfo.getText("artist");
265 if (artist != null) {
266 state.setNowPlayingArtist(artist);
268 String album = titleInfo.getText("album");
270 state.setNowPlayingAlbum(album);
272 String track = titleInfo.getText("track");
274 state.setNowPlayingTrack(track);
278 private boolean setConfigProperties() throws IOException {
279 String url = statusUrl + URL_DEVICE_INFO;
280 logger.debug("Refreshing URL: {}", url);
282 Deviceinfo deviceinfo = getDocument(url, Deviceinfo.class);
283 if (deviceinfo != null) {
284 config.setZoneCount(deviceinfo.getDeviceZones());
288 * The maximum volume is received from the telnet connection in the
289 * form of the MVMAX property. It is not always received reliable however,
290 * so we're using a default for now.
292 config.setMainVolumeMax(DenonMarantzConfiguration.MAX_VOLUME);
294 // if deviceinfo is null, something went wrong (and is logged in getDocument catch blocks)
295 return (deviceinfo != null);
298 private void refreshHttpProperties() throws IOException {
299 logger.trace("Refreshing Denon status");
303 updateSecondaryZones();
308 private <T> T getDocument(String uri, Class<T> response) throws IOException {
310 String result = HttpUtil.executeUrl("GET", uri, REQUEST_TIMEOUT_MS);
311 logger.trace("result of getDocument for uri '{}':\r\n{}", uri, result);
313 if (result != null && !result.isBlank()) {
314 JAXBContext jc = JAXBContext.newInstance(response);
315 XMLInputFactory xif = XMLInputFactory.newInstance();
316 xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
317 xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
318 XMLStreamReader xsr = xif
319 .createXMLStreamReader(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
320 xsr = new PropertyRenamerDelegate(xsr);
322 @SuppressWarnings("unchecked")
323 T obj = (T) jc.createUnmarshaller().unmarshal(xsr);
327 } catch (UnmarshalException e) {
328 logger.debug("Failed to unmarshal xml document: {}", e.getMessage());
329 } catch (JAXBException e) {
330 logger.debug("Unexpected error occurred during unmarshalling of document: {}", e.getMessage());
331 } catch (XMLStreamException e) {
332 logger.debug("Communication error: {}", e.getMessage());
339 private <T, S> T postDocument(String uri, Class<T> response, S request) throws IOException {
341 JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
342 Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
343 StringWriter sw = new StringWriter();
344 jaxbMarshaller.marshal(request, sw);
346 ByteArrayInputStream inputStream = new ByteArrayInputStream(sw.toString().getBytes(StandardCharsets.UTF_8));
347 String result = HttpUtil.executeUrl("POST", uri, inputStream, CONTENT_TYPE_XML, REQUEST_TIMEOUT_MS);
349 if (result != null && !result.isBlank()) {
350 JAXBContext jcResponse = JAXBContext.newInstance(response);
352 @SuppressWarnings("unchecked")
353 T obj = (T) jcResponse.createUnmarshaller()
354 .unmarshal(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
358 } catch (JAXBException e) {
359 logger.debug("Encoding error in post", e);
365 private static class PropertyRenamerDelegate extends StreamReaderDelegate {
367 public PropertyRenamerDelegate(XMLStreamReader xsr) {
372 public String getAttributeLocalName(int index) {
373 return Introspector.decapitalize(super.getAttributeLocalName(index)).intern();
377 public String getLocalName() {
378 return Introspector.decapitalize(super.getLocalName()).intern();