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