]> git.basschouten.com Git - openhab-addons.git/blob
73d643654e9384469cf2480400090923c180cc4d
[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.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;
27
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;
36
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;
62
63 /**
64  * This class makes the connection to the receiver and manages it.
65  * It is also responsible for sending commands to the receiver.
66  * *
67  *
68  * @author Jeroen Idserda - Initial Contribution (1.x Binding)
69  * @author Jan-Willem Veldhuis - Refactored for 2.x
70  */
71 @NonNullByDefault
72 public class DenonMarantzHttpConnector extends DenonMarantzConnector {
73
74     private Logger logger = LoggerFactory.getLogger(DenonMarantzHttpConnector.class);
75
76     private static final int REQUEST_TIMEOUT_MS = 5000; // 5 seconds
77
78     // Main URL for the receiver
79     private static final String URL_MAIN = "formMainZone_MainZoneXml.xml";
80
81     // Main Zone Status URL
82     private static final String URL_ZONE_MAIN = "formMainZone_MainZoneXmlStatus.xml";
83
84     // Secondary zone lite status URL (contains less info)
85     private static final String URL_ZONE_SECONDARY_LITE = "formZone%d_Zone%dXmlStatusLite.xml";
86
87     // Device info URL
88     private static final String URL_DEVICE_INFO = "Deviceinfo.xml";
89
90     // URL to send app commands to
91     private static final String URL_APP_COMMAND = "AppCommand.xml";
92
93     private static final String CONTENT_TYPE_XML = "application/xml";
94
95     private final String cmdUrl;
96
97     private final String statusUrl;
98
99     private final HttpClient httpClient;
100
101     private @Nullable ScheduledFuture<?> pollingJob;
102
103     private boolean legacyApiSupported = true;
104
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;
112     }
113
114     public DenonMarantzState getState() {
115         return state;
116     }
117
118     /**
119      * Set up the connection to the receiver by starting to poll the HTTP API.
120      */
121     @Override
122     public void connect() {
123         if (!isPolling()) {
124             logger.debug("HTTP polling started.");
125             try {
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());
130                 return;
131             } catch (InterruptedException e) {
132                 logger.debug("Interrupted while retrieving document: {}", e.getMessage());
133                 Thread.currentThread().interrupt();
134             }
135
136             pollingJob = scheduler.scheduleWithFixedDelay(() -> {
137                 try {
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());
142                     stopPolling();
143                 } catch (RuntimeException e) {
144                     /**
145                      * We need to catch this RuntimeException, as otherwise the polling stops.
146                      * Log as error as it could be a user configuration error.
147                      */
148                     StringBuilder sb = new StringBuilder();
149                     for (StackTraceElement s : e.getStackTrace()) {
150                         sb.append(s.toString()).append("\n");
151                     }
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();
156                 }
157             }, 0, config.httpPollingInterval, TimeUnit.SECONDS);
158         }
159     }
160
161     private boolean isPolling() {
162         ScheduledFuture<?> pollingJob = this.pollingJob;
163         return pollingJob != null && !pollingJob.isCancelled();
164     }
165
166     private void stopPolling() {
167         ScheduledFuture<?> pollingJob = this.pollingJob;
168         if (pollingJob != null) {
169             pollingJob.cancel(true);
170             logger.debug("HTTP polling stopped.");
171         }
172     }
173
174     /**
175      * Shutdown the http client
176      */
177     @Override
178     public void dispose() {
179         logger.debug("disposing connector");
180
181         stopPolling();
182     }
183
184     @Override
185     protected void internalSendCommand(String command) {
186         logger.debug("Sending command '{}'", command);
187         if (command.isBlank()) {
188             logger.warn("Trying to send empty command");
189             return;
190         }
191
192         String url = cmdUrl + URLEncoder.encode(command, Charset.defaultCharset());
193         logger.trace("Calling url {}", url);
194
195         httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).send(new Response.CompleteListener() {
196             @Override
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());
200                 }
201             }
202         });
203     }
204
205     private void updateMain() throws TimeoutException, ExecutionException, InterruptedException {
206         String url = statusUrl + URL_MAIN;
207         logger.trace("Refreshing URL: {}", url);
208
209         try {
210             Main statusMain = getDocument(url, Main.class);
211             if (statusMain != null) {
212                 state.setPower(statusMain.getPower().getValue());
213             }
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");
218             } else {
219                 logger.debug("Failed to update main by legacy API: {}", e.getMessage());
220             }
221         }
222     }
223
224     private void updateMainZone() throws TimeoutException, ExecutionException, InterruptedException {
225         String url = statusUrl + URL_ZONE_MAIN;
226         logger.trace("Refreshing URL: {}", url);
227
228         try {
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());
235
236                 if (config.inputOptions == null) {
237                     config.inputOptions = mainZone.getInputFuncList();
238                 }
239
240                 StringType surroundMode = mainZone.getSurrMode();
241                 if (surroundMode == null) {
242                     logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
243                 } else {
244                     state.setSurroundProgram(surroundMode.getValue());
245                 }
246             }
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");
251             } else {
252                 logger.debug("Failed to update main zone by legacy API: {}", e.getMessage());
253             }
254         }
255     }
256
257     private void updateMainZoneByAppCommand() throws TimeoutException, ExecutionException, InterruptedException {
258         String url = statusUrl + URL_APP_COMMAND;
259         logger.trace("Refreshing URL: {}", url);
260
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);
263
264         try {
265             AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
266
267             if (response != null) {
268                 for (CommandRx rx : response.getCommands()) {
269                     String inputSource = rx.getSource();
270                     if (inputSource != null) {
271                         state.setInput(inputSource);
272                     }
273                     Boolean power = rx.getZone1();
274                     if (power != null) {
275                         state.setMainZonePower(power.booleanValue());
276                     }
277                     BigDecimal volume = rx.getVolume();
278                     if (volume != null) {
279                         state.setMainVolume(volume);
280                     }
281                     Boolean mute = rx.getMute();
282                     if (mute != null) {
283                         state.setMute(mute.booleanValue());
284                     }
285                     String surroundMode = rx.getSurround();
286                     if (surroundMode != null) {
287                         state.setSurroundProgram(surroundMode);
288                     }
289                 }
290             }
291         } catch (HttpCommunicationException e) {
292             logger.debug("Failed to update main zone by app command: {}", e.getMessage());
293         }
294     }
295
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);
300             try {
301                 ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
302                 if (zoneSecondary != null) {
303                     switch (i) {
304                         // maximum 2 secondary zones are supported
305                         case 2:
306                             state.setZone2Power(zoneSecondary.getPower().getValue());
307                             state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
308                             state.setZone2Mute(zoneSecondary.getMute().getValue());
309                             state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
310                             break;
311                         case 3:
312                             state.setZone3Power(zoneSecondary.getPower().getValue());
313                             state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
314                             state.setZone3Mute(zoneSecondary.getMute().getValue());
315                             state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
316                             break;
317                         case 4:
318                             state.setZone4Power(zoneSecondary.getPower().getValue());
319                             state.setZone4Volume(zoneSecondary.getMasterVolume().getValue());
320                             state.setZone4Mute(zoneSecondary.getMute().getValue());
321                             state.setZone4Input(zoneSecondary.getInputFuncSelect().getValue());
322                             break;
323                     }
324                 }
325             } catch (HttpCommunicationException e) {
326                 logger.debug("Failed to update zone {}: {}", i, e.getMessage());
327             }
328         }
329     }
330
331     private void updateDisplayInfo() throws TimeoutException, ExecutionException, InterruptedException {
332         String url = statusUrl + URL_APP_COMMAND;
333         logger.trace("Refreshing URL: {}", url);
334
335         AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
336         try {
337             AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
338
339             if (response == null) {
340                 return;
341             }
342             CommandRx titleInfo = response.getCommands().get(0);
343             String artist = titleInfo.getText("artist");
344             if (artist != null) {
345                 state.setNowPlayingArtist(artist);
346             }
347             String album = titleInfo.getText("album");
348             if (album != null) {
349                 state.setNowPlayingAlbum(album);
350             }
351             String track = titleInfo.getText("track");
352             if (track != null) {
353                 state.setNowPlayingTrack(track);
354             }
355         } catch (HttpCommunicationException e) {
356             logger.debug("Failed to update display info: {}", e.getMessage());
357         }
358     }
359
360     private boolean setConfigProperties()
361             throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
362         String url = statusUrl + URL_DEVICE_INFO;
363         logger.debug("Refreshing URL: {}", url);
364
365         Deviceinfo deviceinfo = getDocument(url, Deviceinfo.class);
366         if (deviceinfo != null) {
367             config.setZoneCount(deviceinfo.getDeviceZones());
368         }
369
370         /**
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.
374          */
375         config.setMainVolumeMax(DenonMarantzConfiguration.MAX_VOLUME);
376
377         // if deviceinfo is null, something went wrong (and is logged in getDocument catch blocks)
378         return (deviceinfo != null);
379     }
380
381     private void refreshHttpProperties() throws TimeoutException, ExecutionException, InterruptedException {
382         logger.trace("Refreshing Denon status");
383
384         if (legacyApiSupported) {
385             updateMain();
386             updateMainZone();
387         }
388
389         if (!legacyApiSupported) {
390             updateMainZoneByAppCommand();
391         }
392
393         updateSecondaryZones();
394         updateDisplayInfo();
395     }
396
397     @Nullable
398     private <T> T getDocument(String uri, Class<T> response)
399             throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
400         try {
401             Request request = httpClient.newRequest(uri).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
402                     .method(HttpMethod.GET);
403
404             ContentResponse contentResponse = request.send();
405
406             String result = contentResponse.getContentAsString();
407             int status = contentResponse.getStatus();
408
409             logger.trace("result of getDocument for uri '{}' (status code {}):\r\n{}", uri, status, result);
410
411             if (!HttpStatus.isSuccess(status)) {
412                 throw new HttpCommunicationException("Error retrieving document for uri '" + uri + "'", status);
413             }
414
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);
423
424                 @SuppressWarnings("unchecked")
425                 T obj = (T) jc.createUnmarshaller().unmarshal(xsr);
426
427                 return obj;
428             }
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());
435         }
436
437         return null;
438     }
439
440     @Nullable
441     private <T, S> T postDocument(String uri, Class<T> response, S request)
442             throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
443         try {
444             JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
445             Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
446             StringWriter sw = new StringWriter();
447             jaxbMarshaller.marshal(request, sw);
448
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);
452
453             ContentResponse contentResponse = httpRequest.send();
454
455             String result = contentResponse.getContentAsString();
456             int status = contentResponse.getStatus();
457
458             logger.trace("result of postDocument for uri '{}' (status code {}):\r\n{}", uri, status, result);
459
460             if (!HttpStatus.isSuccess(status)) {
461                 throw new HttpCommunicationException("Error retrieving document for uri '" + uri + "'", status);
462             }
463
464             if (result != null && !result.isBlank()) {
465                 JAXBContext jcResponse = JAXBContext.newInstance(response);
466
467                 @SuppressWarnings("unchecked")
468                 T obj = (T) jcResponse.createUnmarshaller()
469                         .unmarshal(new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)));
470
471                 return obj;
472             }
473         } catch (JAXBException e) {
474             logger.debug("Encoding error in post", e);
475         }
476
477         return null;
478     }
479
480     private static class PropertyRenamerDelegate extends StreamReaderDelegate {
481
482         public PropertyRenamerDelegate(XMLStreamReader xsr) {
483             super(xsr);
484         }
485
486         @Override
487         public String getAttributeLocalName(int index) {
488             return Introspector.decapitalize(super.getAttributeLocalName(index)).intern();
489         }
490
491         @Override
492         public String getLocalName() {
493             return Introspector.decapitalize(super.getLocalName()).intern();
494         }
495     }
496 }