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