2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.panasonicbdp.internal.handler;
15 import static org.eclipse.jetty.http.HttpStatus.OK_200;
16 import static org.openhab.binding.panasonicbdp.internal.PanaBlurayBindingConstants.*;
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;
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;
61 * The {@link PanaBlurayHandler} is responsible for handling commands, which are
62 * sent to one of the channels.
64 * @author Michael Lobstein - Initial contribution
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;
72 private @Nullable ScheduledFuture<?> refreshJob;
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;
84 private final TranslationProvider translationProvider;
85 private final LocaleProvider localeProvider;
86 private final @Nullable Bundle bundle;
88 public PanaBlurayHandler(Thing thing, HttpClient httpClient, @Reference TranslationProvider translationProvider,
89 @Reference LocaleProvider localeProvider) {
91 this.httpClient = httpClient;
92 this.translationProvider = translationProvider;
93 this.localeProvider = localeProvider;
94 this.bundle = FrameworkUtil.getBundle(PanaBlurayHandler.class);
98 public void initialize() {
99 logger.debug("Initializing Panasonic Blu-ray Player handler.");
100 PanaBlurayConfiguration config = getConfigAs(PanaBlurayConfiguration.class);
102 this.thingTypeUID = thing.getThingTypeUID();
104 final String host = config.hostName;
105 final String playerKey = config.playerKey;
107 if (!host.isBlank()) {
108 urlStr = urlStr.replace("%host%", host);
109 nonceUrlStr = nonceUrlStr.replace("%host%", host);
111 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.hostname");
115 if (!playerKey.isBlank()) {
116 if (playerKey.length() != 32) {
117 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.keyerror");
120 this.playerKey = playerKey;
123 refreshInterval = config.refresh;
125 updateStatus(ThingStatus.UNKNOWN);
126 startAutomaticRefresh();
130 * Start the job to periodically get a status update from the player
132 private void startAutomaticRefresh() {
133 ScheduledFuture<?> refreshJob = this.refreshJob;
134 if (refreshJob == null || refreshJob.isCancelled()) {
135 this.refreshJob = scheduler.scheduleWithFixedDelay(this::refreshPlayerStatus, 0, refreshInterval,
141 * Sends commands to the player to get status information and updates the channels
143 private void refreshPlayerStatus() {
144 final String[] playerStatusRespArr = sendCommand(REVIEW_POST_CMD, urlStr).split(CRLF);
146 if (playerStatusRespArr.length == 1 && playerStatusRespArr[0].isBlank()) {
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);
153 if (playerStatusArr.length >= 4) {
154 if (getThing().getStatus() != ThingStatus.ONLINE) {
155 updateStatus(ThingStatus.ONLINE);
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);
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);
176 updateState(POWER, OnOffType.from(!OFF_STATUS.equals(playerMode)));
180 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.polling");
184 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.polling");
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);
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);
196 if (pstArr.length >= 2) {
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]);
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);
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);
215 if (getStatusArr.length >= 10) {
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]);
231 public void dispose() {
232 ScheduledFuture<?> refreshJob = this.refreshJob;
233 if (refreshJob != null) {
234 refreshJob.cancel(true);
235 this.refreshJob = null;
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
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;
268 logger.debug("Invalid control command: {}", command);
272 commandStr = command.toString();
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");
280 // if command authentication enabled, get nonce to create authKey and add it to the POST fields
282 final String nonce = sendCommand(GET_NONCE_CMD, nonceUrlStr).trim();
283 if (nonce.isBlank()) {
285 } else if (nonce.length() != 32) {
286 logger.debug("Error retrieving nonce, message was: {}", nonce);
287 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.nonce");
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");
300 // send the command to the player
301 sendCommand(fields, urlStr);
303 logger.debug("Unsupported command: {}", command);
309 * Sends a command to the player using a pre-built post body
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
315 private String sendCommand(Fields fields, String url) {
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();
321 final String output = response.getContentAsString();
322 logger.trace("Response status: {}, response: {}", response.getStatus(), output);
324 if (response.getStatus() != OK_200) {
325 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.polling");
327 } else if (output.startsWith(PLAYER_CMD_ERR)) {
328 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "@text/error.invalid");
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(),
339 Thread.currentThread().interrupt();
345 * Returns true if the player is currently in a playing mode.
347 private boolean isPlayingMode() {
348 return !(STOP_STATUS.equals(playerMode) || OPEN_STATUS.equals(playerMode) || OFF_STATUS.equals(playerMode));
352 * Returns true if any of the time or chapter channels are linked depending on which thing type is used.
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);
359 return isLinked(TIME_ELAPSED);
363 public boolean isLinked(String channelName) {
364 final Channel channel = this.thing.getChannel(channelName);
365 return channel != null ? isLinked(channel.getUID()) : false;
369 * Returns a SHA-256 hash of the input string
371 * @param input the input string to generate the hash from
372 * @return the 256 bit hash string
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));
378 // convert byte array into signum representation
379 final BigInteger number = new BigInteger(1, hash);
381 // convert message digest into hex value
382 final StringBuilder hexString = new StringBuilder(number.toString(16));
384 // pad with leading zeros
385 while (hexString.length() < 32) {
386 hexString.insert(0, "0");
389 return hexString.toString().toUpperCase();