]> git.basschouten.com Git - openhab-addons.git/blob
57a4593a71e27a0175e556959a979f224a522e6c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.denonmarantz.internal.connector.http;
14
15 import java.beans.Introspector;
16 import java.io.ByteArrayInputStream;
17 import java.io.IOException;
18 import java.io.StringWriter;
19 import java.io.UnsupportedEncodingException;
20 import java.net.URLEncoder;
21 import java.nio.charset.Charset;
22 import java.nio.charset.StandardCharsets;
23 import java.util.concurrent.ScheduledExecutorService;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26
27 import javax.xml.bind.JAXBContext;
28 import javax.xml.bind.JAXBException;
29 import javax.xml.bind.Marshaller;
30 import javax.xml.bind.UnmarshalException;
31 import javax.xml.stream.XMLInputFactory;
32 import javax.xml.stream.XMLStreamException;
33 import javax.xml.stream.XMLStreamReader;
34 import javax.xml.stream.util.StreamReaderDelegate;
35
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.entities.Deviceinfo;
44 import org.openhab.binding.denonmarantz.internal.xml.entities.Main;
45 import org.openhab.binding.denonmarantz.internal.xml.entities.ZoneStatus;
46 import org.openhab.binding.denonmarantz.internal.xml.entities.ZoneStatusLite;
47 import org.openhab.binding.denonmarantz.internal.xml.entities.commands.AppCommandRequest;
48 import org.openhab.binding.denonmarantz.internal.xml.entities.commands.AppCommandResponse;
49 import org.openhab.binding.denonmarantz.internal.xml.entities.commands.CommandRx;
50 import org.openhab.binding.denonmarantz.internal.xml.entities.commands.CommandTx;
51 import org.openhab.core.io.net.http.HttpUtil;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
54
55 /**
56  * This class makes the connection to the receiver and manages it.
57  * It is also responsible for sending commands to the receiver.
58  * *
59  *
60  * @author Jeroen Idserda - Initial Contribution (1.x Binding)
61  * @author Jan-Willem Veldhuis - Refactored for 2.x
62  */
63 public class DenonMarantzHttpConnector extends DenonMarantzConnector {
64
65     private Logger logger = LoggerFactory.getLogger(DenonMarantzHttpConnector.class);
66
67     private static final int REQUEST_TIMEOUT_MS = 5000; // 5 seconds
68
69     // Main URL for the receiver
70     private static final String URL_MAIN = "formMainZone_MainZoneXml.xml";
71
72     // Main Zone Status URL
73     private static final String URL_ZONE_MAIN = "formMainZone_MainZoneXmlStatus.xml";
74
75     // Secondary zone lite status URL (contains less info)
76     private static final String URL_ZONE_SECONDARY_LITE = "formZone%d_Zone%dXmlStatusLite.xml";
77
78     // Device info URL
79     private static final String URL_DEVICE_INFO = "Deviceinfo.xml";
80
81     // URL to send app commands to
82     private static final String URL_APP_COMMAND = "AppCommand.xml";
83
84     private static final String CONTENT_TYPE_XML = "application/xml";
85
86     private final String cmdUrl;
87
88     private final String statusUrl;
89
90     private final HttpClient httpClient;
91
92     private ScheduledFuture<?> pollingJob;
93
94     public DenonMarantzHttpConnector(DenonMarantzConfiguration config, DenonMarantzState state,
95             ScheduledExecutorService scheduler, HttpClient httpClient) {
96         this.config = config;
97         this.scheduler = scheduler;
98         this.state = state;
99         this.cmdUrl = String.format("http://%s:%d/goform/formiPhoneAppDirect.xml?", config.getHost(),
100                 config.getHttpPort());
101         this.statusUrl = String.format("http://%s:%d/goform/", config.getHost(), config.getHttpPort());
102         this.httpClient = httpClient;
103     }
104
105     public DenonMarantzState getState() {
106         return state;
107     }
108
109     /**
110      * Set up the connection to the receiver by starting to poll the HTTP API.
111      */
112     @Override
113     public void connect() {
114         if (!isPolling()) {
115             logger.debug("HTTP polling started.");
116             try {
117                 setConfigProperties();
118             } catch (IOException e) {
119                 logger.debug("IO error while retrieving document:", e);
120                 state.connectionError("IO error while connecting to AVR: " + e.getMessage());
121                 return;
122             }
123
124             pollingJob = scheduler.scheduleWithFixedDelay(() -> {
125                 try {
126                     refreshHttpProperties();
127                 } catch (IOException e) {
128                     logger.debug("IO error while retrieving document", e);
129                     state.connectionError("IO error while connecting to AVR: " + e.getMessage());
130                     stopPolling();
131                 } catch (RuntimeException e) {
132                     /**
133                      * We need to catch this RuntimeException, as otherwise the polling stops.
134                      * Log as error as it could be a user configuration error.
135                      */
136                     StringBuilder sb = new StringBuilder();
137                     for (StackTraceElement s : e.getStackTrace()) {
138                         sb.append(s.toString()).append("\n");
139                     }
140                     logger.error("Error while polling Http: \"{}\". Stacktrace: \n{}", e.getMessage(), sb.toString());
141                 }
142             }, 0, config.httpPollingInterval, TimeUnit.SECONDS);
143         }
144     }
145
146     private boolean isPolling() {
147         return pollingJob != null && !pollingJob.isCancelled();
148     }
149
150     private void stopPolling() {
151         if (isPolling()) {
152             pollingJob.cancel(true);
153             logger.debug("HTTP polling stopped.");
154         }
155     }
156
157     /**
158      * Shutdown the http client
159      */
160     @Override
161     public void dispose() {
162         logger.debug("disposing connector");
163
164         stopPolling();
165     }
166
167     @Override
168     protected void internalSendCommand(String command) {
169         logger.debug("Sending command '{}'", command);
170         if (command == null || command.isBlank()) {
171             logger.warn("Trying to send empty command");
172             return;
173         }
174
175         try {
176             String url = cmdUrl + URLEncoder.encode(command, Charset.defaultCharset().displayName());
177             logger.trace("Calling url {}", url);
178
179             httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).send(new Response.CompleteListener() {
180                 @Override
181                 public void onComplete(Result result) {
182                     if (result.getResponse().getStatus() != 200) {
183                         logger.warn("Error {} while sending command", result.getResponse().getReason());
184                     }
185                 }
186             });
187
188         } catch (UnsupportedEncodingException e) {
189             logger.warn("Error sending command", e);
190         }
191     }
192
193     private void updateMain() throws IOException {
194         String url = statusUrl + URL_MAIN;
195         logger.trace("Refreshing URL: {}", url);
196
197         Main statusMain = getDocument(url, Main.class);
198         if (statusMain != null) {
199             state.setPower(statusMain.getPower().getValue());
200         }
201     }
202
203     private void updateMainZone() throws IOException {
204         String url = statusUrl + URL_ZONE_MAIN;
205         logger.trace("Refreshing URL: {}", url);
206
207         ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
208         if (mainZone != null) {
209             state.setInput(mainZone.getInputFuncSelect().getValue());
210             state.setMainVolume(mainZone.getMasterVolume().getValue());
211             state.setMainZonePower(mainZone.getPower().getValue());
212             state.setMute(mainZone.getMute().getValue());
213
214             if (config.inputOptions == null) {
215                 config.inputOptions = mainZone.getInputFuncList();
216             }
217
218             if (mainZone.getSurrMode() == null) {
219                 logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
220             } else {
221                 state.setSurroundProgram(mainZone.getSurrMode().getValue());
222             }
223         }
224     }
225
226     private void updateSecondaryZones() throws IOException {
227         for (int i = 2; i <= config.getZoneCount(); i++) {
228             String url = String.format("%s" + URL_ZONE_SECONDARY_LITE, statusUrl, i, i);
229             logger.trace("Refreshing URL: {}", url);
230             ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
231             if (zoneSecondary != null) {
232                 switch (i) {
233                     // maximum 2 secondary zones are supported
234                     case 2:
235                         state.setZone2Power(zoneSecondary.getPower().getValue());
236                         state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
237                         state.setZone2Mute(zoneSecondary.getMute().getValue());
238                         state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
239                         break;
240                     case 3:
241                         state.setZone3Power(zoneSecondary.getPower().getValue());
242                         state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
243                         state.setZone3Mute(zoneSecondary.getMute().getValue());
244                         state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
245                         break;
246                     case 4:
247                         state.setZone4Power(zoneSecondary.getPower().getValue());
248                         state.setZone4Volume(zoneSecondary.getMasterVolume().getValue());
249                         state.setZone4Mute(zoneSecondary.getMute().getValue());
250                         state.setZone4Input(zoneSecondary.getInputFuncSelect().getValue());
251                         break;
252                 }
253             }
254         }
255     }
256
257     private void updateDisplayInfo() throws IOException {
258         String url = statusUrl + URL_APP_COMMAND;
259         logger.trace("Refreshing URL: {}", url);
260
261         AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
262         AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
263
264         if (response != null) {
265             CommandRx titleInfo = response.getCommands().get(0);
266             state.setNowPlayingArtist(titleInfo.getText("artist"));
267             state.setNowPlayingAlbum(titleInfo.getText("album"));
268             state.setNowPlayingTrack(titleInfo.getText("track"));
269         }
270     }
271
272     private boolean setConfigProperties() throws IOException {
273         String url = statusUrl + URL_DEVICE_INFO;
274         logger.debug("Refreshing URL: {}", url);
275
276         Deviceinfo deviceinfo = getDocument(url, Deviceinfo.class);
277         if (deviceinfo != null) {
278             config.setZoneCount(deviceinfo.getDeviceZones());
279         }
280
281         /**
282          * The maximum volume is received from the telnet connection in the
283          * form of the MVMAX property. It is not always received reliable however,
284          * so we're using a default for now.
285          */
286         config.setMainVolumeMax(DenonMarantzConfiguration.MAX_VOLUME);
287
288         // if deviceinfo is null, something went wrong (and is logged in getDocument catch blocks)
289         return (deviceinfo != null);
290     }
291
292     private void refreshHttpProperties() throws IOException {
293         logger.trace("Refreshing Denon status");
294
295         updateMain();
296         updateMainZone();
297         updateSecondaryZones();
298         updateDisplayInfo();
299     }
300
301     @Nullable
302     private <T> T getDocument(String uri, Class<T> response) throws IOException {
303         try {
304             String result = HttpUtil.executeUrl("GET", uri, REQUEST_TIMEOUT_MS);
305             logger.trace("result of getDocument for uri '{}':\r\n{}", uri, result);
306
307             if (result != null && !result.isBlank()) {
308                 JAXBContext jc = JAXBContext.newInstance(response);
309                 XMLInputFactory xif = XMLInputFactory.newInstance();
310                 xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
311                 xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
312                 XMLStreamReader xsr = xif
313                         .createXMLStreamReader(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
314                 xsr = new PropertyRenamerDelegate(xsr);
315
316                 @SuppressWarnings("unchecked")
317                 T obj = (T) jc.createUnmarshaller().unmarshal(xsr);
318
319                 return obj;
320             }
321         } catch (UnmarshalException e) {
322             logger.debug("Failed to unmarshal xml document: {}", e.getMessage());
323         } catch (JAXBException e) {
324             logger.debug("Unexpected error occurred during unmarshalling of document: {}", e.getMessage());
325         } catch (XMLStreamException e) {
326             logger.debug("Communication error: {}", e.getMessage());
327         }
328
329         return null;
330     }
331
332     @Nullable
333     private <T, S> T postDocument(String uri, Class<T> response, S request) throws IOException {
334         try {
335             JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
336             Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
337             StringWriter sw = new StringWriter();
338             jaxbMarshaller.marshal(request, sw);
339
340             ByteArrayInputStream inputStream = new ByteArrayInputStream(sw.toString().getBytes(StandardCharsets.UTF_8));
341             String result = HttpUtil.executeUrl("POST", uri, inputStream, CONTENT_TYPE_XML, REQUEST_TIMEOUT_MS);
342
343             if (result != null && !result.isBlank()) {
344                 JAXBContext jcResponse = JAXBContext.newInstance(response);
345
346                 @SuppressWarnings("unchecked")
347                 T obj = (T) jcResponse.createUnmarshaller()
348                         .unmarshal(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
349
350                 return obj;
351             }
352         } catch (JAXBException e) {
353             logger.debug("Encoding error in post", e);
354         }
355
356         return null;
357     }
358
359     private static class PropertyRenamerDelegate extends StreamReaderDelegate {
360
361         public PropertyRenamerDelegate(XMLStreamReader xsr) {
362             super(xsr);
363         }
364
365         @Override
366         public String getAttributeLocalName(int index) {
367             return Introspector.decapitalize(super.getAttributeLocalName(index)).intern();
368         }
369
370         @Override
371         public String getLocalName() {
372             return Introspector.decapitalize(super.getLocalName()).intern();
373         }
374     }
375 }