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.samsungtv.internal.service;
15 import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*;
17 import java.util.ArrayList;
18 import java.util.Arrays;
19 import java.util.List;
20 import java.util.concurrent.ScheduledExecutorService;
21 import java.util.concurrent.TimeUnit;
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.binding.samsungtv.internal.Utils;
26 import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler;
27 import org.openhab.binding.samsungtv.internal.protocol.KeyCode;
28 import org.openhab.binding.samsungtv.internal.protocol.RemoteController;
29 import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerException;
30 import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerLegacy;
31 import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerWebSocket;
32 import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService;
33 import org.openhab.core.io.net.http.WebSocketFactory;
34 import org.openhab.core.library.types.DecimalType;
35 import org.openhab.core.library.types.IncreaseDecreaseType;
36 import org.openhab.core.library.types.OnOffType;
37 import org.openhab.core.library.types.RawType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.library.types.UpDownType;
40 import org.openhab.core.thing.ThingStatusDetail;
41 import org.openhab.core.types.Command;
42 import org.openhab.core.types.RefreshType;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
47 * The {@link RemoteControllerService} is responsible for handling remote
48 * controller commands.
50 * @author Pauli Anttila - Initial contribution
51 * @author Martin van Wingerden - Some changes for manually configured devices
52 * @author Arjan Mels - Implemented websocket interface for recent TVs
53 * @author Nick Waterton - added power state monitoring for Frame TV's, some refactoring, sendkeys()
56 public class RemoteControllerService implements SamsungTvService {
58 private final Logger logger = LoggerFactory.getLogger(RemoteControllerService.class);
60 public static final String SERVICE_NAME = "RemoteControlReceiver";
62 private final List<String> supportedCommandsUpnp = Arrays.asList(KEY_CODE, POWER, CHANNEL);
63 private final List<String> supportedCommandsNonUpnp = Arrays.asList(KEY_CODE, VOLUME, MUTE, POWER, CHANNEL,
64 BROWSER_URL, STOP_BROWSER, SOURCE_APP);
65 private final List<String> supportedCommandsArt = Arrays.asList(ART_MODE, ART_JSON, ART_LABEL, ART_IMAGE,
66 ART_BRIGHTNESS, ART_COLOR_TEMPERATURE);
67 private static final List<String> REFRESH_CHANNELS = Arrays.asList();
68 private static final List<String> refreshArt = Arrays.asList(ART_BRIGHTNESS);
69 private static final List<String> refreshApps = Arrays.asList(SOURCE_APP);
70 private static final List<String> art2022 = Arrays.asList(ART_MODE, SET_ART_MODE);
74 private String previousApp = "None";
75 private final int keyTiming = 300;
77 private long busyUntil = System.currentTimeMillis();
79 public boolean artMode = false;
80 public boolean justStarted = true;
81 /* retry connection count */
82 private int retryCount = 0;
84 public final SamsungTvHandler handler;
86 private final RemoteController remoteController;
88 public RemoteControllerService(String host, int port, boolean upnp, SamsungTvHandler handler)
89 throws RemoteControllerException {
90 logger.debug("{}: Creating a Samsung TV RemoteController service: is UPNP:{}", host, upnp);
93 this.handler = handler;
96 remoteController = new RemoteControllerLegacy(host, port, "openHAB", "openHAB");
97 remoteController.openConnection();
99 remoteController = new RemoteControllerWebSocket(host, port, "openHAB", "openHAB", this);
101 } catch (RemoteControllerException e) {
102 throw new RemoteControllerException("Cannot create RemoteControllerService", e);
107 public String getServiceName() {
112 public List<String> getSupportedChannelNames(boolean refresh) {
113 // no refresh channels for UPNP remotecontroller
114 List<String> supported = new ArrayList<>(refresh ? upnp ? Arrays.asList() : REFRESH_CHANNELS
115 : upnp ? supportedCommandsUpnp : supportedCommandsNonUpnp);
116 if (getArtModeSupported()) {
117 supported.addAll(refresh ? refreshArt : supportedCommandsArt);
119 if (getArtMode2022()) {
120 supported.addAll(refresh ? Arrays.asList() : art2022);
122 if (remoteController.noApps() && getPowerState() && refresh) {
123 supported.addAll(refreshApps);
126 logger.trace("{}: getSupportedChannelNames: {}", host, supported);
132 public boolean checkConnection() {
133 return remoteController.isConnected();
137 public void start() {
139 if (!checkConnection()) {
140 remoteController.openConnection();
142 } catch (RemoteControllerException e) {
143 reportError("Cannot connect to remote control service", e);
151 remoteController.close();
152 } catch (RemoteControllerException ignore) {
158 * Clears the UPnP cache, or reconnects a websocket if diconnected
159 * Here we reconnect the websocket
162 public void clearCache() {
167 public boolean isUpnp() {
172 public boolean handleCommand(String channel, Command command) {
173 logger.trace("{}: Received channel: {}, command: {}", host, channel, Utils.truncCmd(command));
175 boolean result = false;
176 if (!checkConnection() && !SET_ART_MODE.equals(channel)) {
177 logger.debug("{}: RemoteController is not connected", host);
178 if (getArtMode2022() && retryCount < 4) {
180 logger.debug("{}: Reconnecting RemoteController, retry: {}", host, retryCount);
182 return handler.handleCommand(channel, command, 3000);
184 logger.warn("{}: TV is not responding - not reconnecting", host);
190 if (command == RefreshType.REFRESH) {
193 remoteController.updateCurrentApp();
197 remoteController.getArtmodeStatus("get_current_artwork");
200 remoteController.getArtmodeStatus("get_brightness");
202 case ART_COLOR_TEMPERATURE:
203 remoteController.getArtmodeStatus("get_color_temperature");
211 if (command instanceof StringType) {
212 remoteController.sendUrl(command.toString());
218 if (command instanceof OnOffType) {
219 if (command.equals(OnOffType.ON)) {
220 return handleCommand(SOURCE_APP, new StringType(""));
222 sendKeys(KeyCode.KEY_EXIT, 2000);
229 if (command instanceof StringType) {
230 remoteController.sendSourceApp(command.toString());
236 if (command instanceof OnOffType) {
238 // websocket uses KEY_POWER
239 if (OnOffType.ON.equals(command) != getPowerState()) {
240 // send key only to toggle state
241 sendKeys(KeyCode.KEY_POWER);
242 if (getArtMode2022()) {
243 if (!getPowerState() & !artMode) {
244 // second key press to get out of art mode, once tv online
245 List<Object> commands = new ArrayList<>();
247 commands.add(KeyCode.KEY_POWER);
249 updateArtMode(OnOffType.OFF.equals(command), 9000);
251 updateArtMode(OnOffType.OFF.equals(command), 1000);
256 // legacy controller uses KEY_POWERON/OFF
257 if (command.equals(OnOffType.ON)) {
258 sendKeys(KeyCode.KEY_POWERON);
260 sendKeys(KeyCode.KEY_POWEROFF);
268 // Used to manually set art mode for >=2022 Frame TV's
269 logger.trace("{}: Setting Artmode to: {} artmode is: {}", host, command, artMode);
270 if (command instanceof OnOffType) {
271 handler.valueReceived(SET_ART_MODE, OnOffType.from(OnOffType.ON.equals(command)));
272 if (OnOffType.ON.equals(command) != artMode || justStarted) {
274 updateArtMode(OnOffType.ON.equals(command));
281 if (command instanceof OnOffType) {
282 // websocket uses KEY_POWER
283 // send key only to toggle state when power = off
284 if (!getPowerState()) {
285 if (OnOffType.ON.equals(command)) {
287 sendKeys(KeyCode.KEY_POWER);
289 } else if (artMode) {
290 // really switch off (long press of power)
291 sendKeys(KeyCode.KEY_POWER, 4000);
295 sendKeys(KeyCode.KEY_POWER);
297 if (getArtMode2022()) {
298 if (OnOffType.ON.equals(command)) {
299 if (!getPowerState()) {
300 // wait for TV to come online
301 updateArtMode(true, 3000);
303 updateArtMode(true, 1000);
306 this.artMode = false;
314 if (command instanceof StringType) {
315 String artJson = command.toString();
316 if (!artJson.contains("\"id\"")) {
317 artJson = artJson.replaceFirst("}$", ",}");
319 remoteController.getArtmodeStatus(artJson);
326 if (command instanceof RawType) {
327 remoteController.getArtmodeStatus("send_image", command.toFullString());
328 } else if (command instanceof StringType) {
329 if (command.toString().startsWith("data:image")) {
330 remoteController.getArtmodeStatus("send_image", command.toString());
331 } else if (channel.equals(ART_LABEL)) {
332 remoteController.getArtmodeStatus("select_image", command.toString());
339 if (command instanceof DecimalType decimalCommand) {
340 int value = decimalCommand.intValue();
341 remoteController.getArtmodeStatus("set_brightness", String.valueOf(value / 10));
346 case ART_COLOR_TEMPERATURE:
347 if (command instanceof DecimalType decimalCommand) {
348 int value = Math.max(-5, Math.min(decimalCommand.intValue(), 5));
349 remoteController.getArtmodeStatus("set_color_temperature", String.valueOf(value));
355 if (command instanceof StringType) {
356 // split on [, +], but not if encloded in "" or {}
357 String[] cmds = command.toString().strip().split("(?=(?:(?:[^\"]*\"){2})*[^\"]*$)(?![^{]*})[, +]+",
359 List<Object> commands = new ArrayList<>();
360 for (String cmd : cmds) {
362 logger.trace("{}: Procesing command: {}", host, cmd);
363 if (cmd.startsWith("\"") || cmd.startsWith("{")) {
364 // remove leading and trailing "
365 cmd = cmd.replaceAll("^\"|\"$", "");
367 if (!cmd.startsWith("{")) {
370 } else if (cmd.matches("-?\\d{2,5}")) {
371 commands.add(Integer.parseInt(cmd));
373 String ucmd = cmd.toUpperCase();
374 commands.add(KeyCode.valueOf(ucmd.startsWith("KEY_") ? ucmd : "KEY_" + ucmd));
376 } catch (IllegalArgumentException e) {
377 logger.warn("{}: Remote control: unsupported cmd {} channel {}, {}", host, cmd, channel,
382 if (!commands.isEmpty()) {
390 if (command instanceof OnOffType) {
391 sendKeys(KeyCode.KEY_MUTE);
397 if (command instanceof UpDownType || command instanceof IncreaseDecreaseType) {
398 if (command.equals(UpDownType.UP) || command.equals(IncreaseDecreaseType.INCREASE)) {
399 sendKeys(KeyCode.KEY_VOLUP);
401 sendKeys(KeyCode.KEY_VOLDOWN);
408 if (command instanceof DecimalType decimalCommand) {
409 KeyCode[] codes = String.valueOf(decimalCommand.intValue()).chars()
410 .mapToObj(c -> KeyCode.valueOf("KEY_" + String.valueOf((char) c))).toArray(KeyCode[]::new);
411 List<Object> commands = new ArrayList<>(Arrays.asList(codes));
412 commands.add(KeyCode.KEY_ENTER);
418 logger.warn("{}: Remote control: unsupported channel: {}", host, channel);
422 logger.warn("{}: Remote control: wrong command type {} channel {}", host, command, channel);
427 public synchronized void sendKeys(KeyCode key, int press) {
428 sendKeys(Arrays.asList(key), press);
431 public synchronized void sendKeys(KeyCode key) {
432 sendKeys(Arrays.asList(key), 0);
435 public synchronized void sendKeys(List<Object> keys) {
440 * Send sequence of key codes to Samsung TV RemoteController instance.
441 * 300 ms between each key click. If press is > 0 then send key press/release
443 * @param keys List containing key codes/Integer delays to send.
444 * if integer delays are negative, send key press of abs(delay)
445 * @param press int value of length of keypress in ms (0 means Click)
447 public synchronized void sendKeys(List<Object> keys, int press) {
448 int timingInMs = keyTiming;
449 int delay = (int) Math.max(0, busyUntil - System.currentTimeMillis());
451 ScheduledExecutorService scheduler = getScheduler();
452 if (scheduler == null) {
453 logger.warn("{}: Unable to schedule key sequence", host);
456 for (int i = 0; i < keys.size(); i++) {
457 Object key = keys.get(i);
458 if (key instanceof Integer keyAsInt) {
460 delay += Math.max(0, keyAsInt - (2 * timingInMs));
462 press = Math.max(timingInMs, Math.abs(keyAsInt));
467 if (press == 0 && key instanceof KeyCode && key.equals(KeyCode.KEY_BT_VOICE)) {
471 int duration = press;
472 scheduler.schedule(() -> {
474 remoteController.sendKeyPress((KeyCode) key, duration);
476 if (key instanceof String keyAsString) {
477 remoteController.sendKey(keyAsString);
479 remoteController.sendKey((KeyCode) key);
482 }, (i * timingInMs) + delay, TimeUnit.MILLISECONDS);
486 busyUntil = System.currentTimeMillis() + (keys.size() * timingInMs) + delay;
487 logger.trace("{}: Key Sequence Queued", host);
490 private void reportError(String message, RemoteControllerException e) {
491 reportError(ThingStatusDetail.COMMUNICATION_ERROR, message, e);
494 private void reportError(ThingStatusDetail statusDetail, String message, RemoteControllerException e) {
495 handler.reportError(statusDetail, message, e);
498 public void appsUpdated(List<String> apps) {
502 public void updateCurrentApp() {
503 remoteController.updateCurrentApp();
506 public synchronized void currentAppUpdated(String app) {
507 if (!previousApp.equals(app)) {
508 handler.valueReceived(SOURCE_APP, new StringType(app));
513 public void updateArtMode(boolean artMode, int ms) {
515 ScheduledExecutorService scheduler = getScheduler();
516 if (scheduler == null) {
517 logger.warn("{}: Unable to schedule art mode update", host);
519 scheduler.schedule(() -> {
520 updateArtMode(artMode);
521 }, ms, TimeUnit.MILLISECONDS);
525 public synchronized void updateArtMode(boolean artMode) {
526 // manual update of power/art mode for >=2022 frame TV's
527 if (this.artMode == artMode) {
528 logger.debug("{}: Artmode setting is already: {}", host, artMode);
532 logger.debug("{}: Setting power state OFF, Art Mode ON", host);
533 powerUpdated(false, true);
535 logger.debug("{}: Setting power state ON, Art Mode OFF", host);
536 powerUpdated(true, false);
539 currentAppUpdated("artMode");
541 currentAppUpdated("");
543 handler.valueReceived(SET_ART_MODE, OnOffType.from(this.artMode));
544 if (!remoteController.noApps()) {
549 public void powerUpdated(boolean on, boolean artMode) {
550 String powerState = fetchPowerState();
551 if (!getArtMode2022()) {
552 setArtModeSupported(true);
554 if (!"on".equals(powerState)) {
557 currentAppUpdated("");
560 this.artMode = artMode;
561 // order of state updates is important to prevent extraneous transitions in overall state
563 handler.valueReceived(POWER, OnOffType.from(on));
564 handler.valueReceived(ART_MODE, OnOffType.from(artMode));
566 handler.valueReceived(ART_MODE, OnOffType.from(artMode));
567 handler.valueReceived(POWER, OnOffType.from(on));
571 public boolean getArtMode2022() {
572 return handler.getArtMode2022();
575 public void setArtMode2022(boolean artmode) {
576 handler.setArtMode2022(artmode);
579 public boolean getArtModeSupported() {
580 return handler.getArtModeSupported();
583 public void setArtModeSupported(boolean artmode) {
584 handler.setArtModeSupported(artmode);
587 public boolean getPowerState() {
588 return handler.getPowerState();
591 public void setPowerState(boolean power) {
592 handler.setPowerState(power);
595 public String fetchPowerState() {
596 return handler.fetchPowerState();
599 public void setOffline() {
600 handler.setOffline();
603 public void putConfig(String key, String value) {
604 handler.putConfig(key, value);
607 public @Nullable ScheduledExecutorService getScheduler() {
608 return handler.getScheduler();
611 public @Nullable WebSocketFactory getWebSocketFactory() {
612 return handler.getWebSocketFactory();