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.kaleidescape.internal.handler;
15 import static org.openhab.binding.kaleidescape.internal.KaleidescapeBindingConstants.*;
17 import java.util.Arrays;
18 import java.util.Collection;
19 import java.util.HashMap;
20 import java.util.HashSet;
21 import java.util.List;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
27 import javax.measure.Unit;
28 import javax.measure.quantity.Time;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.openhab.binding.kaleidescape.internal.KaleidescapeException;
34 import org.openhab.binding.kaleidescape.internal.KaleidescapeThingActions;
35 import org.openhab.binding.kaleidescape.internal.communication.KaleidescapeConnector;
36 import org.openhab.binding.kaleidescape.internal.communication.KaleidescapeDefaultConnector;
37 import org.openhab.binding.kaleidescape.internal.communication.KaleidescapeIpConnector;
38 import org.openhab.binding.kaleidescape.internal.communication.KaleidescapeMessageEvent;
39 import org.openhab.binding.kaleidescape.internal.communication.KaleidescapeMessageEventListener;
40 import org.openhab.binding.kaleidescape.internal.communication.KaleidescapeSerialConnector;
41 import org.openhab.binding.kaleidescape.internal.configuration.KaleidescapeThingConfiguration;
42 import org.openhab.core.io.transport.serial.SerialPortManager;
43 import org.openhab.core.library.types.NextPreviousType;
44 import org.openhab.core.library.types.OnOffType;
45 import org.openhab.core.library.types.PercentType;
46 import org.openhab.core.library.types.PlayPauseType;
47 import org.openhab.core.library.types.RewindFastforwardType;
48 import org.openhab.core.library.types.StringType;
49 import org.openhab.core.library.unit.Units;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.ThingTypeUID;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.binding.ThingHandlerService;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.State;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
64 * The {@link KaleidescapeHandler} is responsible for handling commands, which are sent to one of the channels.
66 * Based on the Rotel binding by Laurent Garnier
68 * @author Michael Lobstein - Initial contribution
71 public class KaleidescapeHandler extends BaseThingHandler implements KaleidescapeMessageEventListener {
72 private static final long RECON_POLLING_INTERVAL_S = 60;
73 private static final long POLLING_INTERVAL_S = 20;
75 private final Logger logger = LoggerFactory.getLogger(KaleidescapeHandler.class);
76 private final SerialPortManager serialPortManager;
77 private final Map<String, String> cache = new HashMap<>();
79 protected final HttpClient httpClient;
80 protected final Unit<Time> apiSecondUnit = Units.SECOND;
82 private ThingTypeUID thingTypeUID = THING_TYPE_PLAYER;
83 private @Nullable ScheduledFuture<?> reconnectJob;
84 private @Nullable ScheduledFuture<?> pollingJob;
85 private long lastEventReceived = 0;
86 private int updatePeriod = 0;
88 protected KaleidescapeConnector connector = new KaleidescapeDefaultConnector();
89 protected int metaRuntimeMultiple = 1;
90 protected int volume = 0;
91 protected boolean volumeEnabled = false;
92 protected boolean volumeBasicEnabled = false;
93 protected boolean isMuted = false;
94 protected boolean isLoadHighlightedDetails = false;
95 protected boolean isLoadAlbumDetails = false;
96 protected String friendlyName = EMPTY;
97 protected Object sequenceLock = new Object();
99 public KaleidescapeHandler(Thing thing, SerialPortManager serialPortManager, HttpClient httpClient) {
101 this.serialPortManager = serialPortManager;
102 this.httpClient = httpClient;
105 protected void updateChannel(String channelUID, State state) {
106 this.updateState(channelUID, state);
109 protected void updateDetailChannel(String channelUID, State state) {
110 this.updateState(DETAIL + channelUID, state);
113 protected void updateThingProperty(String name, String value) {
114 thing.setProperty(name, value);
117 protected boolean isChannelLinked(String channel) {
118 return isLinked(channel);
122 public void initialize() {
123 final String uid = this.getThing().getUID().getAsString();
124 KaleidescapeThingConfiguration config = getConfigAs(KaleidescapeThingConfiguration.class);
126 this.thingTypeUID = thing.getThingTypeUID();
128 // Check configuration settings
129 String configError = null;
130 final String serialPort = config.serialPort;
131 final String host = config.host;
132 final Integer port = config.port;
133 final Integer updatePeriod = config.updatePeriod;
134 this.isLoadHighlightedDetails = config.loadHighlightedDetails;
135 this.isLoadAlbumDetails = config.loadAlbumDetails;
137 if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) {
138 configError = "undefined serialPort and host configuration settings; please set one of them";
139 } else if (host == null || host.isEmpty()) {
140 if (serialPort != null && serialPort.toLowerCase().startsWith("rfc2217")) {
141 configError = "use host and port configuration settings for a serial over IP connection";
145 configError = "undefined port configuration setting";
146 } else if (port <= 0) {
147 configError = "invalid port configuration setting";
151 if (configError != null) {
152 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configError);
156 if (updatePeriod != null) {
157 this.updatePeriod = updatePeriod;
160 // check if volume is enabled
161 if (config.volumeEnabled) {
162 this.volumeEnabled = true;
163 this.volume = config.initialVolume;
164 this.updateState(VOLUME, new PercentType(this.volume));
165 this.updateState(MUTE, OnOffType.OFF);
166 } else if (config.volumeBasicEnabled) {
167 this.volumeBasicEnabled = true;
170 if (serialPort != null) {
171 connector = new KaleidescapeSerialConnector(serialPortManager, serialPort, uid);
172 } else if (port != null) {
173 connector = new KaleidescapeIpConnector(host, port, uid);
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
176 "Either Serial port or Host & Port must be specifed");
180 updateStatus(ThingStatus.UNKNOWN);
182 scheduleReconnectJob();
183 schedulePollingJob();
187 public void dispose() {
188 cancelReconnectJob();
194 public Collection<Class<? extends ThingHandlerService>> getServices() {
195 return List.of(KaleidescapeThingActions.class);
198 public void handleRawCommand(@Nullable String command) {
199 synchronized (sequenceLock) {
201 connector.sendCommand(command);
202 } catch (KaleidescapeException e) {
203 logger.warn("K Command: {} failed", command);
209 public void handleCommand(ChannelUID channelUID, Command command) {
210 String channel = channelUID.getId();
212 if (getThing().getStatus() != ThingStatus.ONLINE) {
213 logger.debug("Thing is not ONLINE; command {} from channel {} is ignored", command, channel);
216 synchronized (sequenceLock) {
217 if (!connector.isConnected()) {
218 logger.debug("Command {} from channel {} is ignored: connection not established", command, channel);
223 if (command instanceof RefreshType) {
224 handleRefresh(channel);
230 if (command instanceof OnOffType) {
231 connector.sendCommand(command == OnOffType.ON ? LEAVE_STANDBY : ENTER_STANDBY);
235 if (command instanceof PercentType percentCommand) {
236 this.volume = (int) percentCommand.doubleValue();
237 logger.debug("Got volume command {}", this.volume);
238 connector.sendCommand(SEND_EVENT_VOLUME_LEVEL_EQ + this.volume);
242 if (command instanceof OnOffType) {
243 this.isMuted = command == OnOffType.ON ? true : false;
245 connector.sendCommand(SEND_EVENT_MUTE + (this.isMuted ? MUTE_ON : MUTE_OFF));
248 if (command instanceof OnOffType) {
249 connector.sendCommand(command == OnOffType.ON ? MUSIC_REPEAT_ON : MUSIC_REPEAT_OFF);
253 if (command instanceof OnOffType) {
254 connector.sendCommand(command == OnOffType.ON ? MUSIC_RANDOM_ON : MUSIC_RANDOM_OFF);
259 handleControlCommand(command);
261 case CHANNEL_TYPE_SENDCMD:
262 connector.sendCommand(command.toString());
265 logger.debug("Command {} from channel {} failed: unexpected command", command, channel);
268 } catch (KaleidescapeException e) {
269 logger.debug("Command {} from channel {} failed: {}", command, channel, e.getMessage());
270 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Sending command failed");
272 scheduleReconnectJob();
278 * Open the connection with the Kaleidescape component
280 * @return true if the connection is opened successfully or false if not
282 private synchronized boolean openConnection() {
283 connector.addEventListener(this);
286 } catch (KaleidescapeException e) {
287 logger.debug("openConnection() failed: {}", e.getMessage());
289 logger.debug("openConnection(): {}", connector.isConnected() ? "connected" : "disconnected");
290 return connector.isConnected();
294 * Close the connection with the Kaleidescape component
296 private synchronized void closeConnection() {
297 if (connector.isConnected()) {
299 connector.removeEventListener(this);
300 logger.debug("closeConnection(): disconnected");
305 public void onNewMessageEvent(KaleidescapeMessageEvent evt) {
306 lastEventReceived = System.currentTimeMillis();
308 // check if we are in standby
309 if (STANDBY_MSG.equals(evt.getKey())) {
310 if (!ThingStatusDetail.BRIDGE_OFFLINE.equals(thing.getStatusInfo().getStatusDetail())) {
311 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.BRIDGE_OFFLINE, STANDBY_MSG);
316 // Use the Enum valueOf to handle the message based on the event key. Otherwise there would be a huge
317 // case statement here
318 KaleidescapeMessageHandler.valueOf(evt.getKey()).handleMessage(evt.getValue(), this);
320 if (!evt.isCached()) {
321 cache.put(evt.getKey(), evt.getValue());
324 if (ThingStatusDetail.BRIDGE_OFFLINE.equals(thing.getStatusInfo().getStatusDetail())) {
325 // no longer in standby, update the status
326 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.friendlyName);
328 } catch (IllegalArgumentException e) {
329 logger.debug("Unhandled message: key {} = {}", evt.getKey(), evt.getValue());
334 * Schedule the reconnection job
336 private void scheduleReconnectJob() {
337 logger.debug("Schedule reconnect job");
338 cancelReconnectJob();
339 reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
340 synchronized (sequenceLock) {
341 if (!connector.isConnected()) {
342 logger.debug("Trying to reconnect...");
344 String error = EMPTY;
345 if (openConnection()) {
349 // register the connection in the Kaleidescape System log
350 connector.sendCommand(SEND_TO_SYSLOG + "openHAB Kaleidescape Binding version "
351 + org.openhab.core.OpenHAB.getVersion());
353 Set<String> initialCommands = new HashSet<>(Arrays.asList(GET_DEVICE_TYPE_NAME,
354 GET_FRIENDLY_NAME, GET_DEVICE_INFO, GET_SYSTEM_VERSION, GET_DEVICE_POWER_STATE,
355 GET_CINEMASCAPE_MASK, GET_CINEMASCAPE_MODE, GET_SCALE_MODE, GET_SCREEN_MASK,
356 GET_SCREEN_MASK2, GET_VIDEO_MODE, GET_UI_STATE, GET_HIGHLIGHTED_SELECTION,
357 GET_CHILD_MODE_STATE, GET_PLAY_STATUS, GET_MOVIE_LOCATION, GET_MOVIE_MEDIA_TYPE,
358 GET_PLAYING_TITLE_NAME));
360 // Premiere Players and Cinema One support music
361 if (thingTypeUID.equals(THING_TYPE_PLAYER) || thingTypeUID.equals(THING_TYPE_CINEMA_ONE)) {
362 initialCommands.addAll(Arrays.asList(GET_MUSIC_NOW_PLAYING_STATUS,
363 GET_MUSIC_PLAY_STATUS, GET_MUSIC_TITLE));
366 // everything after Premiere Player supports GET_SYSTEM_READINESS_STATE
367 if (!thingTypeUID.equals(THING_TYPE_PLAYER)) {
368 initialCommands.add(GET_SYSTEM_READINESS_STATE);
371 // only Strato supports the GET_*_COLOR commands
372 if (thingTypeUID.equals(THING_TYPE_STRATO)) {
373 initialCommands.addAll(Arrays.asList(GET_VIDEO_COLOR, GET_CONTENT_COLOR));
376 initialCommands.forEach(command -> {
378 connector.sendCommand(command);
379 } catch (KaleidescapeException e) {
380 logger.debug("{}: {}", "Error sending initial commands", e.getMessage());
384 if (this.updatePeriod == 1) {
385 connector.sendCommand(SET_STATUS_CUE_PERIOD_1);
387 } catch (KaleidescapeException e) {
388 error = "First command after connection failed";
389 logger.debug("{}: {}", error, e.getMessage());
393 error = "Reconnection failed";
395 if (!error.equals(EMPTY)) {
396 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
399 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, this.friendlyName);
400 lastEventReceived = System.currentTimeMillis();
403 }, 1, RECON_POLLING_INTERVAL_S, TimeUnit.SECONDS);
407 * Cancel the reconnection job
409 private void cancelReconnectJob() {
410 ScheduledFuture<?> reconnectJob = this.reconnectJob;
411 if (reconnectJob != null) {
412 reconnectJob.cancel(true);
413 this.reconnectJob = null;
418 * Schedule the polling job
420 private void schedulePollingJob() {
421 logger.debug("Schedule polling job");
424 pollingJob = scheduler.scheduleWithFixedDelay(() -> {
425 synchronized (sequenceLock) {
426 if (connector.isConnected()) {
427 logger.debug("Polling the component for updated status...");
431 } catch (KaleidescapeException e) {
432 logger.debug("Polling error: {}", e.getMessage());
435 // if the last successful polling update was more than 1.25 intervals ago,
436 // the component is not responding even though the connection is still good
437 if ((System.currentTimeMillis() - lastEventReceived) > (POLLING_INTERVAL_S * 1.25 * 1000)) {
438 logger.debug("Component not responding to status requests");
439 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
440 "Component not responding to status requests");
442 scheduleReconnectJob();
446 }, POLLING_INTERVAL_S, POLLING_INTERVAL_S, TimeUnit.SECONDS);
450 * Cancel the polling job
452 private void cancelPollingJob() {
453 ScheduledFuture<?> pollingJob = this.pollingJob;
454 if (pollingJob != null) {
455 pollingJob.cancel(true);
456 this.pollingJob = null;
460 private void handleControlCommand(Command command) throws KaleidescapeException {
461 if (command instanceof PlayPauseType) {
462 if (command == PlayPauseType.PLAY) {
463 connector.sendCommand(PLAY);
464 } else if (command == PlayPauseType.PAUSE) {
465 connector.sendCommand(PAUSE);
467 } else if (command instanceof NextPreviousType) {
468 if (command == NextPreviousType.NEXT) {
469 connector.sendCommand(NEXT);
470 } else if (command == NextPreviousType.PREVIOUS) {
471 connector.sendCommand(PREVIOUS);
473 } else if (command instanceof RewindFastforwardType) {
474 if (command == RewindFastforwardType.FASTFORWARD) {
475 connector.sendCommand(SCAN_FORWARD);
476 } else if (command == RewindFastforwardType.REWIND) {
477 connector.sendCommand(SCAN_REVERSE);
480 logger.warn("Unknown control command: {}", command);
484 private void handleRefresh(String channel) throws KaleidescapeException {
487 connector.sendCommand(GET_DEVICE_POWER_STATE, cache.get("DEVICE_POWER_STATE"));
490 updateState(channel, new PercentType(this.volume));
493 updateState(channel, OnOffType.from(this.isMuted));
496 connector.sendCommand(GET_PLAYING_TITLE_NAME, cache.get("TITLE_NAME"));
506 connector.sendCommand(GET_PLAY_STATUS, cache.get("PLAY_STATUS"));
508 case MOVIE_MEDIA_TYPE:
509 connector.sendCommand(GET_MOVIE_MEDIA_TYPE, cache.get("MOVIE_MEDIA_TYPE"));
512 connector.sendCommand(GET_MOVIE_LOCATION, cache.get("MOVIE_LOCATION"));
515 case VIDEO_MODE_COMPOSITE:
516 case VIDEO_MODE_COMPONENT:
517 case VIDEO_MODE_HDMI:
518 connector.sendCommand(GET_VIDEO_MODE, cache.get("VIDEO_MODE"));
521 case VIDEO_COLOR_EOTF:
522 connector.sendCommand(GET_VIDEO_COLOR, cache.get("VIDEO_COLOR"));
525 case CONTENT_COLOR_EOTF:
526 connector.sendCommand(GET_CONTENT_COLOR, cache.get("CONTENT_COLOR"));
529 connector.sendCommand(GET_SCALE_MODE, cache.get("SCALE_MODE"));
533 connector.sendCommand(GET_SCREEN_MASK, cache.get("SCREEN_MASK"));
536 connector.sendCommand(GET_SCREEN_MASK2, cache.get("SCREEN_MASK2"));
538 case CINEMASCAPE_MASK:
539 connector.sendCommand(GET_CINEMASCAPE_MASK, cache.get("GET_CINEMASCAPE_MASK"));
541 case CINEMASCAPE_MODE:
542 connector.sendCommand(GET_CINEMASCAPE_MODE, cache.get("CINEMASCAPE_MODE"));
545 connector.sendCommand(GET_UI_STATE, cache.get("UI_STATE"));
547 case CHILD_MODE_STATE:
548 connector.sendCommand(GET_CHILD_MODE_STATE, cache.get("CHILD_MODE_STATE"));
550 case SYSTEM_READINESS_STATE:
551 connector.sendCommand(GET_SYSTEM_READINESS_STATE, cache.get("SYSTEM_READINESS_STATE"));
553 case HIGHLIGHTED_SELECTION:
554 connector.sendCommand(GET_HIGHLIGHTED_SELECTION, cache.get("HIGHLIGHTED_SELECTION"));
556 case USER_DEFINED_EVENT:
558 case USER_INPUT_PROMPT:
559 updateState(channel, StringType.EMPTY);
563 connector.sendCommand(GET_MUSIC_NOW_PLAYING_STATUS, cache.get("MUSIC_NOW_PLAYING_STATUS"));
568 case MUSIC_TRACK_HANDLE:
569 case MUSIC_ALBUM_HANDLE:
570 case MUSIC_NOWPLAY_HANDLE:
571 connector.sendCommand(GET_MUSIC_TITLE, cache.get("MUSIC_TITLE"));
573 case MUSIC_PLAY_MODE:
574 case MUSIC_PLAY_SPEED:
575 case MUSIC_TRACK_LENGTH:
576 case MUSIC_TRACK_POSITION:
577 case MUSIC_TRACK_PROGRESS:
578 connector.sendCommand(GET_MUSIC_PLAY_STATUS, cache.get("MUSIC_PLAY_STATUS"));
582 case DETAIL_ALBUM_TITLE:
583 case DETAIL_COVER_ART:
584 case DETAIL_COVER_URL:
585 case DETAIL_HIRES_COVER_URL:
588 case DETAIL_RUNNING_TIME:
591 case DETAIL_DIRECTORS:
593 case DETAIL_RATING_REASON:
594 case DETAIL_SYNOPSIS:
596 case DETAIL_COLOR_DESCRIPTION:
598 case DETAIL_ASPECT_RATIO:
599 case DETAIL_DISC_LOCATION:
600 updateState(channel, StringType.EMPTY);