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.*;
16 import static org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration.*;
18 import java.io.ByteArrayInputStream;
19 import java.io.IOException;
20 import java.io.InputStream;
22 import java.net.URISyntaxException;
23 import java.net.http.HttpClient;
24 import java.net.http.HttpRequest;
25 import java.net.http.HttpResponse;
26 import java.net.http.HttpResponse.BodyHandler;
27 import java.net.http.HttpResponse.BodySubscriber;
28 import java.net.http.HttpResponse.ResponseInfo;
29 import java.nio.ByteBuffer;
30 import java.nio.charset.StandardCharsets;
31 import java.time.Duration;
32 import java.util.ArrayList;
33 import java.util.Arrays;
34 import java.util.Collections;
35 import java.util.HashMap;
36 import java.util.List;
38 import java.util.Optional;
39 import java.util.Properties;
40 import java.util.concurrent.CompletableFuture;
41 import java.util.concurrent.CompletionStage;
42 import java.util.concurrent.CountDownLatch;
43 import java.util.concurrent.ExecutionException;
44 import java.util.concurrent.Flow.Subscription;
45 import java.util.concurrent.TimeUnit;
46 import java.util.concurrent.TimeoutException;
47 import java.util.stream.Collectors;
48 import java.util.stream.IntStream;
50 import org.eclipse.jdt.annotation.NonNullByDefault;
51 import org.eclipse.jdt.annotation.Nullable;
52 import org.eclipse.jetty.http.HttpMethod;
53 import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler;
54 import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService;
55 import org.openhab.core.io.net.http.HttpUtil;
56 import org.openhab.core.library.types.DecimalType;
57 import org.openhab.core.library.types.StringType;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
63 import com.google.gson.Gson;
64 import com.google.gson.JsonArray;
65 import com.google.gson.JsonElement;
66 import com.google.gson.JsonObject;
67 import com.google.gson.JsonSyntaxException;
68 import com.google.gson.annotations.SerializedName;
71 * The {@link SmartThingsApiService} is responsible for handling the Smartthings cloud interface
74 * @author Nick Waterton - Initial contribution
77 public class SmartThingsApiService implements SamsungTvService {
79 public static final String SERVICE_NAME = "SmartthingsApi";
80 private static final List<String> SUPPORTED_CHANNELS = Arrays.asList(SOURCE_NAME, SOURCE_ID);
81 private static final List<String> REFRESH_CHANNELS = Arrays.asList(CHANNEL, CHANNEL_NAME, SOURCE_NAME, SOURCE_ID);
83 private static final String SMARTTHINGS_URL = "api.smartthings.com";
84 // Path for the information endpoint note the final /
85 private static final String API_ENDPOINT_V1 = "/v1/";
86 // private static final String INPUT_SOURCE = "/components/main/capabilities/mediaInputSource/status";
87 // private static final String CURRENT_CHANNEL = "/components/main/capabilities/tvChannel/status";
88 private static final String COMPONENTS = "/components/main/status";
89 private static final String DEVICES = "devices";
90 private static final String COMMAND = "/commands";
92 private final Logger logger = LoggerFactory.getLogger(SmartThingsApiService.class);
94 private String host = "";
95 private String apiKey = "";
96 private String deviceId = "";
97 private int RATE_LIMIT = 1000;
98 private int TIMEOUT = 1000; // connection timeout in ms
99 private long prevUpdate = 0;
100 private boolean online = false;
101 private int errorCount = 0;
102 private int MAX_ERRORS = 100;
104 private final SamsungTvHandler handler;
106 private Optional<TvValues> tvInfo = Optional.empty();
107 private boolean subscriptionRunning = false;
108 private Optional<BodyHandlerWrapper> handlerWrapper = Optional.empty();
109 private Optional<STSubscription> subscription = Optional.empty();
111 private Map<String, Object> stateMap = Collections.synchronizedMap(new HashMap<>());
113 public SmartThingsApiService(String host, SamsungTvHandler handler) {
114 this.handler = handler;
116 this.apiKey = handler.configuration.getSmartThingsApiKey();
117 this.deviceId = handler.configuration.getSmartThingsDeviceId();
118 logger.debug("{}: Creating a Samsung TV Smartthings Api service", host);
122 public String getServiceName() {
127 public List<String> getSupportedChannelNames(boolean refresh) {
129 if (subscriptionRunning) {
130 return Arrays.asList();
132 return REFRESH_CHANNELS;
134 logger.trace("{}: getSupportedChannelNames: {}", host, SUPPORTED_CHANNELS);
135 return SUPPORTED_CHANNELS;
138 // Description of tvValues
139 @NonNullByDefault({})
141 class MediaInputSource {
142 ValuesList supportedInputSources;
143 ValuesListMap supportedInputSourcesMap;
149 Values tvChannelName;
162 class ValuesListMap {
166 public String[] getInputList() {
167 return Optional.ofNullable(value).map(a -> Arrays.stream(a).map(b -> b.getId()).toArray(String[]::new))
168 .orElse(new String[0]);
176 public String getId() {
177 return Optional.ofNullable(id).orElse("");
186 public String getDeviceId() {
187 return Optional.ofNullable(deviceId).orElse("");
190 public String getName() {
191 return Optional.ofNullable(name).orElse("");
194 public String getLabel() {
195 return Optional.ofNullable(label).orElse("");
211 @SerializedName(value = "samsungvd.mediaInputSource", alternate = { "mediaInputSource" })
212 MediaInputSource mediaInputSource;
217 public void updateSupportedInputSources(String[] values) {
218 mediaInputSource.supportedInputSources.value = values;
221 public Items[] getItems() {
222 return Optional.ofNullable(items).orElse(new Items[0]);
225 public String[] getSources() {
226 return Optional.ofNullable(mediaInputSource).map(a -> a.supportedInputSources).map(a -> a.value)
227 .orElseGet(() -> getSourcesFromMap());
230 public String[] getSourcesFromMap() {
231 return Optional.ofNullable(mediaInputSource).map(a -> a.supportedInputSourcesMap).map(a -> a.getInputList())
232 .orElse(new String[0]);
235 public String getSourcesString() {
236 return Arrays.asList(getSources()).stream().collect(Collectors.joining(","));
239 public String getInputSource() {
240 return Optional.ofNullable(mediaInputSource).map(a -> a.inputSource).map(a -> a.value).orElse("");
243 public int getInputSourceId() {
244 return IntStream.range(0, getSources().length).filter(i -> getSources()[i].equals(getInputSource()))
245 .findFirst().orElse(-1);
248 public Number getTvChannel() {
249 return Optional.ofNullable(tvChannel).map(a -> a.tvChannel).map(a -> a.value).filter(i -> !i.isBlank())
250 .map(j -> parseTVChannel(j)).orElse(-1f);
253 public String getTvChannelName() {
254 return Optional.ofNullable(tvChannel).map(a -> a.tvChannelName).map(a -> a.value).orElse("");
257 public boolean isError() {
258 return Optional.ofNullable(error).isPresent();
261 public String getError() {
262 String code = Optional.ofNullable(error).map(a -> a.code).orElse("");
263 String message = Optional.ofNullable(error).map(a -> a.message).orElse("");
264 return String.format("%s, %s", code, message);
268 @NonNullByDefault({})
270 public JSONContent(String capability, String action, String value) {
271 Command command = new Command();
272 command.capability = capability;
273 command.command = action;
274 command.arguments = new String[] { value };
275 commands = new Command[] { command };
279 String component = "main";
288 @NonNullByDefault({})
289 class JSONSubscriptionFilter {
290 public JSONSubscriptionFilter(String deviceId) {
291 SubscriptionFilter sub = new SubscriptionFilter();
292 sub.value = new String[] { deviceId };
293 subscriptionFilters = new SubscriptionFilter[] { sub };
296 class SubscriptionFilter {
297 String type = "DEVICEIDS";
301 SubscriptionFilter[] subscriptionFilters;
302 String name = "OpenHAB Subscription";
305 @NonNullByDefault({})
306 class STSubscription {
308 String subscriptionId;
309 String registrationUrl;
312 SubscriptionFilters[] subscriptionFilters;
314 class SubscriptionFilters {
319 public String getSubscriptionId() {
320 return Optional.ofNullable(subscriptionId).orElse("");
323 public String getregistrationUrl() {
324 return Optional.ofNullable(registrationUrl).orElse("");
328 @NonNullByDefault({})
333 DeviceEvent deviceEvent;
334 Optional<TvValues> tvInfo = Optional.empty();
344 String capability; // example "sec.diagnosticsInformation"
345 String attribute; // example "dumpType"
346 JsonElement value; // example "id" or can be an array
350 String subscriptionName;
353 // Array of supportedInputSourcesMap
357 public String getId() {
358 return Optional.ofNullable(id).orElse("");
361 public String getName() {
362 return Optional.ofNullable(name).orElse("");
366 public String toString() {
367 return Map.of("id", getId(), "name", getName()).toString();
371 public String getCapability() {
372 return Optional.ofNullable(capability).orElse("");
375 public String getAttribute() {
376 return Optional.ofNullable(attribute).orElse("");
379 public String getValueType() {
380 return Optional.ofNullable(valueType).orElse("");
383 public List<?> getValuesAsList() throws JsonSyntaxException {
384 if ("array".equals(getValueType())) {
385 JsonArray resultArray = Optional.ofNullable((JsonArray) value.getAsJsonArray())
386 .orElse(new JsonArray());
388 if (resultArray.get(0) instanceof JsonObject) {
389 // Only for Array of supportedInputSourcesMap
390 ValuesList[] values = new Gson().fromJson(resultArray, ValuesList[].class);
391 List<ValuesList> result = Optional.ofNullable(values).map(a -> Arrays.asList(a))
392 .orElse(new ArrayList<ValuesList>());
393 return Optional.ofNullable(result).orElse(List.of());
395 List<String> result = new Gson().fromJson(resultArray, ArrayList.class);
396 return Optional.ofNullable(result).orElse(List.of());
398 } catch (IllegalStateException e) {
404 public String getValue() {
405 if ("string".equals(getValueType())) {
406 return Optional.ofNullable((String) value.getAsString()).orElse("");
412 public void setTvInfo(Optional<TvValues> tvInfo) {
413 this.tvInfo = tvInfo;
416 public boolean getCapabilityAttribute(String capability, String attribute) {
417 return Optional.ofNullable(deviceEvent).map(a -> a.getCapability()).filter(a -> a.equals(capability))
419 && Optional.ofNullable(deviceEvent).map(a -> a.getAttribute()).filter(a -> a.equals(attribute))
423 public String getSwitch() {
424 if (getCapabilityAttribute("switch", "switch")) {
425 return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).orElse("");
430 public String getInputSource() {
431 if (getCapabilityAttribute("mediaInputSource", "inputSource")
432 || getCapabilityAttribute("samsungvd.mediaInputSource", "inputSource")) {
433 return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).orElse("");
438 public String[] getInputSourceList() {
439 if (getCapabilityAttribute("mediaInputSource", "supportedInputSources")) {
440 return deviceEvent.getValuesAsList().toArray(String[]::new);
442 return new String[0];
445 public List<?> getInputSourceMapList() {
446 if (getCapabilityAttribute("samsungvd.mediaInputSource", "supportedInputSourcesMap")) {
447 return deviceEvent.getValuesAsList();
452 public int getInputSourceId() {
453 return this.tvInfo.map(t -> IntStream.range(0, t.getSources().length)
454 .filter(i -> t.getSources()[i].equals(getInputSource())).findFirst().orElse(-1)).orElse(-1);
457 public Number getTvChannel() {
458 if (getCapabilityAttribute("tvChannel", "tvChannel")) {
459 return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).filter(i -> !i.isBlank())
460 .map(j -> parseTVChannel(j)).orElse(-1f);
465 public String getTvChannelName() {
466 if (getCapabilityAttribute("tvChannel", "tvChannelName")) {
467 return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).orElse("");
473 public Number parseTVChannel(@Nullable String channel) {
475 return channel != null
477 channel.replaceAll("\\D+", ".").replaceFirst("^\\D*((\\d+\\.\\d+)|(\\d+)).*", "$1"))
479 } catch (NumberFormatException ignore) {
484 public void updateTV() {
485 if (!tvInfo.isPresent()) {
487 tvInfo.ifPresent(t -> {
488 updateState(CHANNEL_NAME, t.getTvChannelName());
489 updateState(CHANNEL, t.getTvChannel());
490 updateState(SOURCE_NAME, t.getInputSource());
491 updateState(SOURCE_ID, t.getInputSourceId());
497 * Smartthings API HTTP interface
498 * Currently rate limited to 350 requests/minute
500 * @param method the method "GET" or "POST"
501 * @param uri as a URI
502 * @param content to POST (or null)
505 public Optional<String> sendUrl(HttpMethod method, URI uri, @Nullable InputStream content) throws IOException {
506 // need to add header "Authorization":"Bearer " + apiKey;
507 Properties headers = new Properties();
508 headers.put("Authorization", "Bearer " + this.apiKey);
509 logger.trace("{}: Sending {}", host, uri.toURL().toString());
510 Optional<String> response = Optional.ofNullable(HttpUtil.executeUrl(method.toString(), uri.toURL().toString(),
511 headers, content, "application/json", TIMEOUT));
512 if (!response.isPresent()) {
513 throw new IOException("No Data");
515 response.ifPresent(r -> logger.trace("{}: Got response: {}", host, r));
516 response.filter(r -> !r.startsWith("{")).ifPresent(r -> logger.debug("{}: Got response: {}", host, r));
521 * Smartthings API HTTP getter
522 * Currently rate limited to 350 requests/minute
524 * @param value the query to send
527 public synchronized Optional<TvValues> fetchTVProperties(String value) {
528 if (apiKey.isBlank()) {
529 return Optional.empty();
531 Optional<TvValues> tvValues = Optional.empty();
533 String api = API_ENDPOINT_V1 + ((deviceId.isBlank()) ? "" : "devices/") + deviceId + value;
534 URI uri = new URI("https", null, SMARTTHINGS_URL, 443, api, null, null);
535 Optional<String> response = sendUrl(HttpMethod.GET, uri, null);
536 tvValues = response.map(r -> new Gson().fromJson(r, TvValues.class));
537 if (!tvValues.isPresent()) {
538 throw new IOException("No Data - is DeviceID correct?");
540 tvValues.filter(t -> t.isError()).ifPresent(t -> logger.debug("{}: Error: {}", host, t.getError()));
542 } catch (JsonSyntaxException | URISyntaxException | IOException e) {
543 logger.debug("{}: Cannot connect to Smartthings Cloud: {}", host, e.getMessage());
544 if (errorCount++ > MAX_ERRORS) {
545 logger.warn("{}: Too many connection errors, disabling SmartThings", host);
553 * Smartthings API HTTP setter
554 * Currently rate limited to 350 requests/minute
556 * @param capability eg mediaInputSource
557 * @param command eg setInputSource
558 * @param value from acceptible list eg HDMI1, digitalTv, AM etc
559 * @return boolean true if successful
561 public synchronized boolean setTVProperties(String capability, String command, String value) {
562 if (apiKey.isBlank() || deviceId.isBlank()) {
565 Optional<String> response = Optional.empty();
567 String contentString = new Gson().toJson(new JSONContent(capability, command, value));
568 logger.trace("{}: content: {}", host, contentString);
569 InputStream content = new ByteArrayInputStream(contentString.getBytes());
570 String api = API_ENDPOINT_V1 + "devices/" + deviceId + COMMAND;
571 URI uri = new URI("https", null, SMARTTHINGS_URL, 443, api, null, null);
572 response = sendUrl(HttpMethod.POST, uri, content);
573 } catch (JsonSyntaxException | URISyntaxException | IOException e) {
574 logger.debug("{}: Send Command to Smartthings Cloud failed: {}", host, e.getMessage());
576 return response.map(r -> r.contains("ACCEPTED") || r.contains("COMPLETED")).orElse(false);
580 * Smartthings API Subscription
581 * Retrieves the Smartthings API Subscription from a remote service, performing an API call
585 public synchronized Optional<STSubscription> smartthingsSubscription() {
586 if (apiKey.isBlank() || deviceId.isBlank()) {
587 return Optional.empty();
589 Optional<STSubscription> stSub = Optional.empty();
591 logger.info("{}: SSE Creating Smartthings Subscription", host);
592 String contentString = new Gson().toJson(new JSONSubscriptionFilter(deviceId));
593 logger.trace("{}: subscription: {}", host, contentString);
594 InputStream subscriptionFilter = new ByteArrayInputStream(contentString.getBytes());
595 URI uri = new URI("https", null, SMARTTHINGS_URL, 443, "/subscriptions", null, null);
596 Optional<String> response = sendUrl(HttpMethod.POST, uri, subscriptionFilter);
597 stSub = response.map(r -> new Gson().fromJson(r, STSubscription.class));
598 if (!stSub.isPresent()) {
599 throw new IOException("No Data - is DeviceID correct?");
601 } catch (JsonSyntaxException | URISyntaxException | IOException e) {
602 logger.warn("{}: SSE Subscription to Smartthings Cloud failed: {}", host, e.getMessage());
607 public synchronized void startSSE() {
608 if (!subscriptionRunning) {
609 logger.trace("{}: SSE Starting job", host);
610 subscription = smartthingsSubscription();
611 logger.trace("{}: SSE got subscription ID: {}", host,
612 subscription.map(a -> a.getSubscriptionId()).orElse("None"));
613 if (!subscription.map(a -> a.getSubscriptionId()).orElse("").isBlank()) {
619 public void stopSSE() {
620 handlerWrapper.ifPresent(a -> {
622 logger.trace("{}: SSE Stopping job", host);
623 handlerWrapper = Optional.empty();
624 subscriptionRunning = false;
629 * SubscriberWrapper needed to make async SSE stream cancelable
632 @NonNullByDefault({})
633 private static class SubscriberWrapper implements BodySubscriber<Void> {
634 private final CountDownLatch latch;
635 private final BodySubscriber<Void> subscriber;
636 private Subscription subscription;
638 private SubscriberWrapper(BodySubscriber<Void> subscriber, CountDownLatch latch) {
639 this.subscriber = subscriber;
644 public CompletionStage<Void> getBody() {
645 return subscriber.getBody();
649 public void onSubscribe(Subscription subscription) {
650 subscriber.onSubscribe(subscription);
651 this.subscription = subscription;
656 public void onNext(List<ByteBuffer> item) {
657 subscriber.onNext(item);
661 public void onError(Throwable throwable) {
662 subscriber.onError(throwable);
666 public void onComplete() {
667 subscriber.onComplete();
670 public void cancel() {
671 subscription.cancel();
675 @NonNullByDefault({})
676 private static class BodyHandlerWrapper implements BodyHandler<Void> {
677 private final CountDownLatch latch = new CountDownLatch(1);
678 private final BodyHandler<Void> handler;
679 private SubscriberWrapper subscriberWrapper;
680 private int statusCode = -1;
682 private BodyHandlerWrapper(BodyHandler<Void> handler) {
683 this.handler = handler;
687 public BodySubscriber<Void> apply(ResponseInfo responseInfo) {
688 subscriberWrapper = new SubscriberWrapper(handler.apply(responseInfo), latch);
689 this.statusCode = responseInfo.statusCode();
690 return subscriberWrapper;
693 public void waitForEvent(boolean cancel) {
695 CompletableFuture.runAsync(() -> {
699 subscriberWrapper.cancel();
701 } catch (InterruptedException ignore) {
703 }).get(2, TimeUnit.SECONDS);
704 } catch (InterruptedException | ExecutionException | TimeoutException ignore) {
708 public int getStatusCode() {
713 public void cancel() {
718 public void receiveSSEEvents() {
719 subscription.ifPresent(sub -> {
722 URI uri = new URI(sub.getregistrationUrl());
723 HttpClient client = HttpClient.newHttpClient();
724 HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(2)).GET()
725 .header("Authorization", "Bearer " + this.apiKey).build();
726 handlerWrapper = Optional.ofNullable(
727 new BodyHandlerWrapper(HttpResponse.BodyHandlers.ofByteArrayConsumer(b -> processSSEEvent(b))));
728 handlerWrapper.ifPresent(h -> {
729 client.sendAsync(request, h);
731 logger.debug("{}: SSE job {}", host, checkResponseCode() ? "Started" : "Failed");
732 } catch (URISyntaxException e) {
733 logger.warn("{}: SSE URI Exception: {}", host, e.getMessage());
738 boolean checkResponseCode() {
739 int respCode = handlerWrapper.map(a -> a.getStatusCode()).orElse(-1);
740 logger.trace("{}: SSE GOT Response Code: {}", host, respCode);
741 subscriptionRunning = (respCode == 200);
742 return subscriptionRunning;
745 Map<String, String> bytesToMap(byte[] bytes) {
746 String s = new String(bytes, StandardCharsets.UTF_8);
747 // logger.trace("{}: SSE received: {}", host, s);
748 Map<String, String> properties = new HashMap<String, String>();
749 String[] pairs = s.split("\r?\n");
750 for (String pair : pairs) {
751 String[] kv = pair.split(":", 2);
752 properties.put(kv[0].trim(), kv[1].trim());
754 logger.trace("{}: SSE received: {}", host, properties);
759 synchronized void processSSEEvent(Optional<byte[]> bytes) {
760 bytes.ifPresent(b -> {
761 Map<String, String> properties = bytesToMap(b);
762 String rawData = properties.getOrDefault("data", "none");
763 String event = properties.getOrDefault("event", "none");
764 // logger.trace("{}: SSE Decoding event: {}", host, event);
766 case "CONTROL_EVENT":
767 subscriptionRunning = "welcome".equals(rawData);
768 if (!subscriptionRunning) {
769 logger.trace("{}: SSE Subscription ended", host);
776 Optional<STSSEData> data = Optional.ofNullable(new Gson().fromJson(rawData, STSSEData.class));
777 data.ifPresentOrElse(d -> {
779 String[] inputList = d.getInputSourceList();
780 if (inputList.length > 0) {
781 logger.trace("{}: SSE Got input source list: {}", host, Arrays.asList(inputList));
782 tvInfo.ifPresent(a -> a.updateSupportedInputSources(inputList));
784 String inputSource = d.getInputSource();
785 if (!inputSource.isBlank()) {
786 updateState(SOURCE_NAME, inputSource);
787 int sourceId = d.getInputSourceId();
788 logger.trace("{}: SSE Got input source: {} ID: {}", host, inputSource, sourceId);
789 updateState(SOURCE_ID, sourceId);
791 Number tvChannel = d.getTvChannel();
792 if (tvChannel.intValue() != -1) {
793 updateState(CHANNEL, tvChannel);
794 String tvChannelName = d.getTvChannelName();
795 logger.trace("{}: SSE Got TV Channel Name: {} Channel: {}", host, tvChannelName,
797 updateState(CHANNEL_NAME, tvChannelName);
799 String Power = d.getSwitch();
800 if (!Power.isBlank()) {
801 logger.debug("{}: SSE Got TV Power: {}", host, Power);
802 if ("on".equals(Power)) {
803 // handler.putOnline(); // ignore on event for now
805 // handler.setOffline(); // ignore off event for now
808 }, () -> logger.warn("{}: SSE Received NULL data", host));
809 } catch (JsonSyntaxException e) {
810 logger.warn("{}: SmartThingsApiService: Error ({}) in message: {}", host, e.getMessage(),
815 logger.trace("{}: SSE not handling event: {}", host, event);
821 private boolean updateDeviceID(TvValues.Items item) {
822 this.deviceId = item.getDeviceId();
823 logger.debug("{}: found {} device, adding device id {}", host, item.getName(), deviceId);
824 handler.putConfig(SMARTTHINGS_DEVICEID, deviceId);
829 public boolean fetchdata() {
830 if (System.currentTimeMillis() >= prevUpdate + RATE_LIMIT) {
831 if (deviceId.isBlank()) {
832 tvInfo = fetchTVProperties(DEVICES);
833 boolean found = false;
834 if (tvInfo.isPresent()) {
835 TvValues t = tvInfo.get();
836 switch (t.getItems().length) {
839 logger.warn("{}: No devices found - please add your TV to the Smartthings app", host);
842 found = Arrays.asList(t.getItems()).stream().filter(a -> "Samsung TV".equals(a.getName()))
843 .map(a -> updateDeviceID(a)).findFirst().orElse(false);
846 logger.warn("{}: No device Id selected, please enter one of the following:", host);
847 Arrays.asList(t.getItems()).stream().forEach(a -> logger.info("{}: '{}' : {}({})", host,
848 a.getDeviceId(), a.getName(), a.getLabel()));
858 tvInfo = fetchTVProperties(COMPONENTS);
859 prevUpdate = System.currentTimeMillis();
861 return (tvInfo.isPresent());
865 public void start() {
878 public void clearCache() {
884 public boolean isUpnp() {
889 public boolean checkConnection() {
894 public boolean handleCommand(String channel, Command command) {
895 logger.trace("{}: Received channel: {}, command: {}", host, channel, command);
896 if (!checkConnection()) {
897 logger.trace("{}: Smartthings offline", host);
902 return tvInfo.map(t -> {
903 boolean result = false;
904 if (command == RefreshType.REFRESH) {
907 updateState(CHANNEL_NAME, t.getTvChannelName());
910 updateState(CHANNEL, t.getTvChannel());
914 updateState(SOURCE_NAME, t.getInputSource());
915 updateState(SOURCE_ID, t.getInputSourceId());
925 if (command instanceof DecimalType commandAsDecimalType) {
926 int val = commandAsDecimalType.intValue();
927 if (val >= 0 && val < t.getSources().length) {
928 result = setSourceName(t.getSources()[val]);
930 logger.warn("{}: Invalid source ID: {}, acceptable: 0..{}", host, command,
931 t.getSources().length);
936 if (command instanceof StringType) {
937 if (t.getSourcesString().contains(command.toString()) || t.getSourcesString().isBlank()) {
938 result = setSourceName(command.toString());
940 logger.warn("{}: Invalid source Name: {}, acceptable: {}", host, command,
941 t.getSourcesString());
946 logger.warn("{}: Samsung TV doesn't support transmitting for channel '{}'", host, channel);
949 logger.warn("{}: Smartthings: wrong command type {} channel {}", host, command, channel);
957 private void updateState(String channel, Object value) {
958 if (!stateMap.getOrDefault(channel, "None").equals(value)) {
962 handler.valueReceived(channel, new DecimalType((Number) value));
965 handler.valueReceived(channel, new StringType((String) value));
968 stateMap.put(channel, value);
970 logger.trace("{}: Value '{}' for {} hasn't changed, ignoring update", host, value, channel);
974 private boolean setSourceName(String value) {
975 return setTVProperties("mediaInputSource", "setInputSource", value);