]> git.basschouten.com Git - openhab-addons.git/blob
9fbbeaf1d019913be435c1abd1727da7e5dc7d89
[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.panasonicbdp.internal.handler;
14
15 import static org.eclipse.jetty.http.HttpStatus.OK_200;
16 import static org.openhab.binding.panasonicbdp.internal.PanaBlurayBindingConstants.*;
17
18 import java.math.BigInteger;
19 import java.nio.charset.StandardCharsets;
20 import java.security.MessageDigest;
21 import java.security.NoSuchAlgorithmException;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.util.FormContentProvider;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.eclipse.jetty.util.Fields;
34 import org.openhab.binding.panasonicbdp.internal.PanaBlurayConfiguration;
35 import org.openhab.core.i18n.LocaleProvider;
36 import org.openhab.core.i18n.TranslationProvider;
37 import org.openhab.core.library.types.DecimalType;
38 import org.openhab.core.library.types.NextPreviousType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.PlayPauseType;
41 import org.openhab.core.library.types.QuantityType;
42 import org.openhab.core.library.types.RewindFastforwardType;
43 import org.openhab.core.library.types.StringType;
44 import org.openhab.core.thing.Channel;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.ThingTypeUID;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.UnDefType;
54 import org.osgi.framework.Bundle;
55 import org.osgi.framework.FrameworkUtil;
56 import org.osgi.service.component.annotations.Reference;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 /**
61  * The {@link PanaBlurayHandler} is responsible for handling commands, which are
62  * sent to one of the channels.
63  *
64  * @author Michael Lobstein - Initial contribution
65  */
66 @NonNullByDefault
67 public class PanaBlurayHandler extends BaseThingHandler {
68     private final Logger logger = LoggerFactory.getLogger(PanaBlurayHandler.class);
69     private final HttpClient httpClient;
70     private static final int REQUEST_TIMEOUT = 5000;
71
72     private @Nullable ScheduledFuture<?> refreshJob;
73
74     private String urlStr = "http://%host%/WAN/dvdr/dvdr_ctrl.cgi";
75     private String nonceUrlStr = "http://%host%/cgi-bin/get_nonce.cgi";
76     private int refreshInterval = DEFAULT_REFRESH_PERIOD_SEC;
77     private String playerMode = EMPTY;
78     private String playerKey = EMPTY;
79     private boolean debounce = true;
80     private boolean authEnabled = false;
81     private Object sequenceLock = new Object();
82     private ThingTypeUID thingTypeUID = THING_TYPE_BD_PLAYER;
83
84     private final TranslationProvider translationProvider;
85     private final LocaleProvider localeProvider;
86     private final @Nullable Bundle bundle;
87
88     public PanaBlurayHandler(Thing thing, HttpClient httpClient, @Reference TranslationProvider translationProvider,
89             @Reference LocaleProvider localeProvider) {
90         super(thing);
91         this.httpClient = httpClient;
92         this.translationProvider = translationProvider;
93         this.localeProvider = localeProvider;
94         this.bundle = FrameworkUtil.getBundle(PanaBlurayHandler.class);
95     }
96
97     @Override
98     public void initialize() {
99         logger.debug("Initializing Panasonic Blu-ray Player handler.");
100         PanaBlurayConfiguration config = getConfigAs(PanaBlurayConfiguration.class);
101
102         this.thingTypeUID = thing.getThingTypeUID();
103
104         final String host = config.hostName;
105         final String playerKey = config.playerKey;
106
107         if (!host.isBlank()) {
108             urlStr = urlStr.replace("%host%", host);
109             nonceUrlStr = nonceUrlStr.replace("%host%", host);
110         } else {
111             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.hostname");
112             return;
113         }
114
115         if (!playerKey.isBlank()) {
116             if (playerKey.length() != 32) {
117                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.keyerror");
118                 return;
119             }
120             this.playerKey = playerKey;
121             authEnabled = true;
122         }
123         refreshInterval = config.refresh;
124
125         updateStatus(ThingStatus.UNKNOWN);
126         startAutomaticRefresh();
127     }
128
129     /**
130      * Start the job to periodically get a status update from the player
131      */
132     private void startAutomaticRefresh() {
133         ScheduledFuture<?> refreshJob = this.refreshJob;
134         if (refreshJob == null || refreshJob.isCancelled()) {
135             this.refreshJob = scheduler.scheduleWithFixedDelay(this::refreshPlayerStatus, 0, refreshInterval,
136                     TimeUnit.SECONDS);
137         }
138     }
139
140     /**
141      * Sends commands to the player to get status information and updates the channels
142      */
143     private void refreshPlayerStatus() {
144         final String[] playerStatusRespArr = sendCommand(REVIEW_POST_CMD, urlStr).split(CRLF);
145
146         if (playerStatusRespArr.length == 1 && playerStatusRespArr[0].isBlank()) {
147             return;
148         } else if (playerStatusRespArr.length >= 2) {
149             // CMD_REVIEW response second line, 4th group is the status:
150             // F,,,07,00,,8,,,,1,000:00,,05:10,F,FF:FF,0000,0000,0000,0000
151             final String playerStatusArr[] = playerStatusRespArr[1].split(COMMA);
152
153             if (playerStatusArr.length >= 4) {
154                 if (getThing().getStatus() != ThingStatus.ONLINE) {
155                     updateStatus(ThingStatus.ONLINE);
156                 }
157
158                 // update playerMode if different
159                 if (!playerMode.equals(playerStatusArr[3])) {
160                     playerMode = playerStatusArr[3];
161                     final String i18nKey = STATUS_MAP.get(playerMode) != null ? STATUS_MAP.get(playerMode) : "unknown";
162                     updateState(PLAYER_STATUS, new StringType(translationProvider.getText(bundle, "status." + i18nKey,
163                             i18nKey, localeProvider.getLocale())));
164                     updateState(CONTROL, PLAY_STATUS.equals(playerMode) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
165
166                     // playback stopped, tray open, or power switched off, zero out time and chapters
167                     if (!isPlayingMode()) {
168                         updateState(TIME_ELAPSED, UnDefType.UNDEF);
169                         updateState(TIME_TOTAL, UnDefType.UNDEF);
170                         updateState(CHAPTER_CURRENT, UnDefType.UNDEF);
171                         updateState(CHAPTER_TOTAL, UnDefType.UNDEF);
172                     }
173                 }
174
175                 if (debounce) {
176                     updateState(POWER, OnOffType.from(!OFF_STATUS.equals(playerMode)));
177                 }
178                 debounce = true;
179             } else {
180                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.polling");
181                 return;
182             }
183         } else {
184             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.polling");
185             return;
186         }
187
188         // if time/chapter channels are not linked or player is stopped or paused there is no need to make extra calls
189         if (isAnyStatusChannelsLinked() && isPlayingMode() && !PAUSE_STATUS.equals(playerMode)) {
190             final String[] pstRespArr = sendCommand(PST_POST_CMD, urlStr).split(CRLF);
191
192             if (pstRespArr.length >= 2) {
193                 // CMD_PST response second line: 1,1543,0,00000000 (play mode, current time, ?, ?)
194                 final String pstArr[] = pstRespArr[1].split(COMMA);
195
196                 if (pstArr.length >= 2) {
197                     try {
198                         updateState(TIME_ELAPSED, new QuantityType<>(Integer.parseInt(pstArr[1]), API_SECONDS_UNIT));
199                     } catch (NumberFormatException nfe) {
200                         logger.debug("Error parsing time elapsed integer in CMD_PST message: {}", pstRespArr[1]);
201                     }
202                 }
203             }
204
205             // only BD players support the CMD_GET_STATUS command, it returns a 404 error on UHD models
206             if (THING_TYPE_BD_PLAYER.equals(thingTypeUID)
207                     && (isLinked(TIME_TOTAL) || isLinked(CHAPTER_CURRENT) || isLinked(CHAPTER_TOTAL))) {
208                 final String[] getStatusRespArr = sendCommand(GET_STATUS_POST_CMD, urlStr).split(CRLF);
209
210                 if (getStatusRespArr.length >= 2) {
211                     // CMD_GET_STATUS response second line: 1,0,0,1,5999,61440,500,1,16,00000000
212                     // (?, ?, ?, cur time, total time, title#?, ?, chapter #, total chapter, ?)
213                     final String getStatusArr[] = getStatusRespArr[1].split(COMMA);
214
215                     if (getStatusArr.length >= 10) {
216                         try {
217                             updateState(TIME_TOTAL,
218                                     new QuantityType<>(Integer.parseInt(getStatusArr[4]), API_SECONDS_UNIT));
219                             updateState(CHAPTER_CURRENT, new DecimalType(Integer.parseInt(getStatusArr[7])));
220                             updateState(CHAPTER_TOTAL, new DecimalType(Integer.parseInt(getStatusArr[8])));
221                         } catch (NumberFormatException nfe) {
222                             logger.debug("Error parsing integer in CMD_GET_STATUS message: {}", getStatusRespArr[1]);
223                         }
224                     }
225                 }
226             }
227         }
228     }
229
230     @Override
231     public void dispose() {
232         ScheduledFuture<?> refreshJob = this.refreshJob;
233         if (refreshJob != null) {
234             refreshJob.cancel(true);
235             this.refreshJob = null;
236         }
237     }
238
239     @Override
240     public void handleCommand(ChannelUID channelUID, Command command) {
241         synchronized (sequenceLock) {
242             if (command instanceof RefreshType) {
243                 logger.debug("Unsupported refresh command: {}", command);
244             } else if (BUTTON.equals(channelUID.getId()) || POWER.equals(channelUID.getId())
245                     || CONTROL.equals(channelUID.getId())) {
246                 final String commandStr;
247                 if (command instanceof OnOffType) {
248                     commandStr = CMD_POWER + command; // e.g. POWERON or POWEROFF
249                     // if the power is switched on while the polling is running, the switch could bounce back to off,
250                     // set this flag to stop the first polling event from changing the state of the switch to give the
251                     // player time to start up and report the correct power status on the next poll
252                     debounce = false;
253                 } else if (command instanceof PlayPauseType || command instanceof NextPreviousType
254                         || command instanceof RewindFastforwardType) {
255                     if (command == PlayPauseType.PLAY) {
256                         commandStr = CMD_PLAYBACK;
257                     } else if (command == PlayPauseType.PAUSE) {
258                         commandStr = CMD_PAUSE;
259                     } else if (command == NextPreviousType.NEXT) {
260                         commandStr = CMD_SKIPFWD;
261                     } else if (command == NextPreviousType.PREVIOUS) {
262                         commandStr = CMD_SKIPREV;
263                     } else if (command == RewindFastforwardType.FASTFORWARD) {
264                         commandStr = CMD_CUE;
265                     } else if (command == RewindFastforwardType.REWIND) {
266                         commandStr = CMD_REV;
267                     } else {
268                         logger.debug("Invalid control command: {}", command);
269                         return;
270                     }
271                 } else {
272                     commandStr = command.toString();
273                 }
274
275                 // build the fields to POST the RC_ command string
276                 Fields fields = new Fields();
277                 fields.add("cCMD_RC_" + commandStr + ".x", "100");
278                 fields.add("cCMD_RC_" + commandStr + ".y", "100");
279
280                 // if command authentication enabled, get nonce to create authKey and add it to the POST fields
281                 if (authEnabled) {
282                     final String nonce = sendCommand(GET_NONCE_CMD, nonceUrlStr).trim();
283                     if (nonce.isBlank()) {
284                         return;
285                     } else if (nonce.length() != 32) {
286                         logger.debug("Error retrieving nonce, message was: {}", nonce);
287                         updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.nonce");
288                         return;
289                     }
290                     try {
291                         fields.add("cAUTH_FORM", playerKey.substring(0, playerKey.contains("2") ? 2 : 3));
292                         fields.add("cAUTH_VALUE", getAuthKey(playerKey + nonce));
293                     } catch (NoSuchAlgorithmException e) {
294                         logger.debug("Error creating auth key: {}", e.getMessage());
295                         updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.authkey");
296                         return;
297                     }
298                 }
299
300                 // send the command to the player
301                 sendCommand(fields, urlStr);
302             } else {
303                 logger.debug("Unsupported command: {}", command);
304             }
305         }
306     }
307
308     /**
309      * Sends a command to the player using a pre-built post body
310      *
311      * @param fields a pre-built post body to send to the player
312      * @param url the url to receive the command
313      * @return the response string from the player
314      */
315     private String sendCommand(Fields fields, String url) {
316         try {
317             logger.trace("POST url: {}, data: {}", url, fields);
318             ContentResponse response = httpClient.POST(url).agent(USER_AGENT).method(HttpMethod.POST)
319                     .content(new FormContentProvider(fields)).timeout(REQUEST_TIMEOUT, TimeUnit.MILLISECONDS).send();
320
321             final String output = response.getContentAsString();
322             logger.trace("Response status: {}, response: {}", response.getStatus(), output);
323
324             if (response.getStatus() != OK_200) {
325                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.polling");
326                 return EMPTY;
327             } else if (output.startsWith(PLAYER_CMD_ERR)) {
328                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.invalid");
329                 return EMPTY;
330             }
331
332             return output;
333         } catch (TimeoutException | ExecutionException e) {
334             logger.debug("Error executing command: {}, {}", fields.getNames().iterator().next(), e.getMessage());
335             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, "@text/error.exception");
336         } catch (InterruptedException e) {
337             logger.debug("InterruptedException executing command: {}, {}", fields.getNames().iterator().next(),
338                     e.getMessage());
339             Thread.currentThread().interrupt();
340         }
341         return EMPTY;
342     }
343
344     /*
345      * Returns true if the player is currently in a playing mode.
346      */
347     private boolean isPlayingMode() {
348         return !(STOP_STATUS.equals(playerMode) || OPEN_STATUS.equals(playerMode) || OFF_STATUS.equals(playerMode));
349     }
350
351     /*
352      * Returns true if any of the time or chapter channels are linked depending on which thing type is used.
353      */
354     private boolean isAnyStatusChannelsLinked() {
355         if (THING_TYPE_BD_PLAYER.equals(thingTypeUID)) {
356             return isLinked(TIME_ELAPSED) || isLinked(TIME_TOTAL) || isLinked(CHAPTER_CURRENT)
357                     || isLinked(CHAPTER_TOTAL);
358         }
359         return isLinked(TIME_ELAPSED);
360     }
361
362     @Override
363     public boolean isLinked(String channelName) {
364         final Channel channel = this.thing.getChannel(channelName);
365         return channel != null ? isLinked(channel.getUID()) : false;
366     }
367
368     /**
369      * Returns a SHA-256 hash of the input string
370      *
371      * @param input the input string to generate the hash from
372      * @return the 256 bit hash string
373      */
374     private String getAuthKey(String input) throws NoSuchAlgorithmException {
375         final MessageDigest md = MessageDigest.getInstance(SHA_256_ALGORITHM);
376         final byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
377
378         // convert byte array into signum representation
379         final BigInteger number = new BigInteger(1, hash);
380
381         // convert message digest into hex value
382         final StringBuilder hexString = new StringBuilder(number.toString(16));
383
384         // pad with leading zeros
385         while (hexString.length() < 32) {
386             hexString.insert(0, "0");
387         }
388
389         return hexString.toString().toUpperCase();
390     }
391 }