2 * Copyright (c) 2010-2023 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.vizio.internal.handler;
15 import static org.openhab.binding.vizio.internal.VizioBindingConstants.*;
17 import java.math.BigDecimal;
18 import java.util.ArrayList;
19 import java.util.List;
20 import java.util.Locale;
21 import java.util.Optional;
22 import java.util.Random;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.util.ssl.SslContextFactory;
30 import org.openhab.binding.vizio.internal.VizioConfiguration;
31 import org.openhab.binding.vizio.internal.VizioException;
32 import org.openhab.binding.vizio.internal.VizioStateDescriptionOptionProvider;
33 import org.openhab.binding.vizio.internal.communication.VizioCommunicator;
34 import org.openhab.binding.vizio.internal.dto.app.CurrentApp;
35 import org.openhab.binding.vizio.internal.dto.applist.VizioApp;
36 import org.openhab.binding.vizio.internal.dto.applist.VizioApps;
37 import org.openhab.binding.vizio.internal.dto.audio.Audio;
38 import org.openhab.binding.vizio.internal.dto.audio.ItemAudio;
39 import org.openhab.binding.vizio.internal.dto.input.CurrentInput;
40 import org.openhab.binding.vizio.internal.dto.inputlist.InputList;
41 import org.openhab.binding.vizio.internal.dto.power.PowerMode;
42 import org.openhab.binding.vizio.internal.enums.KeyCommand;
43 import org.openhab.core.config.core.Configuration;
44 import org.openhab.core.io.net.http.HttpClientFactory;
45 import org.openhab.core.library.types.NextPreviousType;
46 import org.openhab.core.library.types.OnOffType;
47 import org.openhab.core.library.types.PercentType;
48 import org.openhab.core.library.types.PlayPauseType;
49 import org.openhab.core.library.types.RewindFastforwardType;
50 import org.openhab.core.library.types.StringType;
51 import org.openhab.core.thing.Channel;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.Thing;
54 import org.openhab.core.thing.ThingStatus;
55 import org.openhab.core.thing.ThingStatusDetail;
56 import org.openhab.core.thing.binding.BaseThingHandler;
57 import org.openhab.core.thing.util.ThingWebClientUtil;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.openhab.core.types.StateOption;
61 import org.openhab.core.types.UnDefType;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
65 import com.google.gson.Gson;
66 import com.google.gson.JsonSyntaxException;
69 * The {@link VizioHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Michael Lobstein - Initial contribution
75 public class VizioHandler extends BaseThingHandler {
76 private final Logger logger = LoggerFactory.getLogger(VizioHandler.class);
77 private final HttpClientFactory httpClientFactory;
78 private @Nullable HttpClient httpClient;
79 private final VizioStateDescriptionOptionProvider stateDescriptionProvider;
80 private final String dbAppsJson;
82 private @Nullable ScheduledFuture<?> refreshJob;
83 private @Nullable ScheduledFuture<?> metadataRefreshJob;
85 private VizioCommunicator communicator;
86 private List<VizioApp> userConfigApps = new ArrayList<VizioApp>();
87 private Object sequenceLock = new Object();
89 private int pairingDeviceId = -1;
90 private int pairingToken = -1;
91 private Long currentInputHash = 0L;
92 private Long currentVolumeHash = 0L;
93 private String currentApp = EMPTY;
94 private String currentInput = EMPTY;
95 private boolean currentMute = false;
96 private int currentVolume = -1;
97 private boolean powerOn = false;
98 private boolean debounce = true;
100 public VizioHandler(Thing thing, HttpClientFactory httpClientFactory,
101 VizioStateDescriptionOptionProvider stateDescriptionProvider, String vizioAppsJson) {
103 this.httpClientFactory = httpClientFactory;
104 this.stateDescriptionProvider = stateDescriptionProvider;
105 this.dbAppsJson = vizioAppsJson;
106 this.communicator = new VizioCommunicator(httpClientFactory.getCommonHttpClient(), EMPTY, -1, EMPTY);
110 public void initialize() {
111 logger.debug("Initializing Vizio handler");
112 final Gson gson = new Gson();
113 VizioConfiguration config = getConfigAs(VizioConfiguration.class);
116 String host = config.hostName;
117 final @Nullable String authToken = config.authToken;
119 String appListJson = config.appListJson;
121 if (host == null || host.isEmpty()) {
122 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
123 "@text/offline.configuration-error-hostname");
125 } else if (host.contains(":")) {
127 host = "[" + host + "]";
130 final String httpClientName = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
132 httpClient = httpClientFactory.createHttpClient(httpClientName, new SslContextFactory.Client(true));
133 final HttpClient localHttpClient = this.httpClient;
134 if (localHttpClient != null) {
135 localHttpClient.start();
136 this.communicator = new VizioCommunicator(localHttpClient, host, config.port,
137 authToken != null ? authToken : EMPTY);
139 } catch (Exception e) {
141 "Long running HttpClient for Vizio handler {} cannot be started. Creating Handler failed. Exception: {}",
142 httpClientName, e.getMessage(), e);
143 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
147 if (authToken == null) {
148 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
149 "@text/offline.configuration-error-authtoken");
153 // if app list is not supplied in thing configuration, populate it from the json db
154 if (appListJson == null) {
155 appListJson = dbAppsJson;
157 // Update thing configuration (persistent) - store app list from db into thing so the user can update it
158 Configuration configuration = this.getConfig();
159 configuration.put(PROPERTY_APP_LIST_JSON, appListJson);
160 this.updateConfiguration(configuration);
164 VizioApps appsFromJson = gson.fromJson(appListJson, VizioApps.class);
165 if (appsFromJson != null && !appsFromJson.getApps().isEmpty()) {
166 userConfigApps = appsFromJson.getApps();
168 List<StateOption> appListOptions = new ArrayList<>();
169 userConfigApps.forEach(app -> {
170 appListOptions.add(new StateOption(app.getName(), app.getName()));
173 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), ACTIVE_APP),
176 } catch (JsonSyntaxException e) {
177 logger.debug("Invalid App List Configuration in thing configuration. Exception: {}", e.getMessage(), e);
178 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
179 "@text/offline.configuration-error-applist");
183 updateStatus(ThingStatus.UNKNOWN);
185 startVizioStateRefresh();
186 startPeriodicRefresh();
190 * Start the job that queries the Vizio TV every 10 seconds to get its current status
192 private void startVizioStateRefresh() {
193 ScheduledFuture<?> refreshJob = this.refreshJob;
194 if (refreshJob == null || refreshJob.isCancelled()) {
195 this.refreshJob = scheduler.scheduleWithFixedDelay(this::refreshVizioState, 5, 10, TimeUnit.SECONDS);
200 * Get current status from the Vizio TV and update the channels
202 private void refreshVizioState() {
203 synchronized (sequenceLock) {
205 PowerMode polledPowerMode = communicator.getPowerMode();
207 if (debounce && !polledPowerMode.getItems().isEmpty()) {
208 int powerMode = polledPowerMode.getItems().get(0).getValue();
209 if (powerMode == 1) {
211 updateState(POWER, OnOffType.ON);
212 } else if (powerMode == 0) {
214 updateState(POWER, OnOffType.OFF);
216 logger.debug("Unknown power mode {}, for response object: {}", powerMode, polledPowerMode);
219 updateStatus(ThingStatus.ONLINE);
220 } catch (VizioException e) {
221 logger.debug("Unable to retrieve Vizio TV power mode info. Exception: {}", e.getMessage(), e);
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
223 "@text/offline.communication-error-get-power");
226 if (powerOn && (isLinked(VOLUME) || isLinked(MUTE))) {
228 Audio audioSettings = communicator.getCurrentAudioSettings();
230 Optional<ItemAudio> volumeItem = audioSettings.getItems().stream()
231 .filter(i -> VOLUME.equals(i.getCname())).findFirst();
232 if (debounce && volumeItem.isPresent()) {
233 currentVolumeHash = volumeItem.get().getHashval();
236 int polledVolume = Integer.parseInt(volumeItem.get().getValue());
237 if (polledVolume != currentVolume) {
238 currentVolume = polledVolume;
239 updateState(VOLUME, new PercentType(BigDecimal.valueOf(currentVolume)));
241 } catch (NumberFormatException e) {
242 logger.debug("Unable to parse volume value {} as int", volumeItem.get().getValue());
246 Optional<ItemAudio> muteItem = audioSettings.getItems().stream()
247 .filter(i -> MUTE.equals(i.getCname())).findFirst();
248 if (debounce && muteItem.isPresent()) {
249 String polledMute = muteItem.get().getValue().toUpperCase(Locale.ENGLISH);
251 if (ON.equals(polledMute) || OFF.equals(polledMute)) {
252 if (ON.equals(polledMute) && !currentMute) {
253 updateState(MUTE, OnOffType.ON);
255 } else if (OFF.equals(polledMute) && currentMute) {
256 updateState(MUTE, OnOffType.OFF);
260 logger.debug("Unknown mute mode {}, for response object: {}", polledMute, audioSettings);
263 } catch (VizioException e) {
264 logger.debug("Unable to retrieve Vizio TV current audio settings. Exception: {}", e.getMessage(),
266 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
267 "@text/offline.communication-error-get-audio");
271 if (powerOn && isLinked(SOURCE)) {
273 CurrentInput polledInputState = communicator.getCurrentInput();
275 if (debounce && !polledInputState.getItems().isEmpty()
276 && !currentInput.equals(polledInputState.getItems().get(0).getValue())) {
277 currentInput = polledInputState.getItems().get(0).getValue();
278 currentInputHash = polledInputState.getItems().get(0).getHashval();
279 updateState(SOURCE, new StringType(currentInput));
281 } catch (VizioException e) {
282 logger.debug("Unable to retrieve Vizio TV current input. Exception: {}", e.getMessage(), e);
283 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
284 "@text/offline.communication-error-get-input");
288 if (powerOn && isLinked(ACTIVE_APP)) {
291 CurrentApp polledApp = communicator.getCurrentApp();
292 Optional<VizioApp> currentAppData = userConfigApps.stream()
293 .filter(a -> a.getConfig().getAppId().equals(polledApp.getItem().getValue().getAppId())
294 && a.getConfig().getNameSpace()
295 .equals(polledApp.getItem().getValue().getNameSpace()))
298 if (currentAppData.isPresent()) {
299 if (!currentApp.equals(currentAppData.get().getName())) {
300 currentApp = currentAppData.get().getName();
301 updateState(ACTIVE_APP, new StringType(currentApp));
306 int appId = Integer.parseInt(polledApp.getItem().getValue().getAppId());
307 updateState(ACTIVE_APP, new StringType(String.format(UNKNOWN_APP_STR, appId,
308 polledApp.getItem().getValue().getNameSpace())));
309 } catch (NumberFormatException nfe) {
310 // Non-numeric appId received, eg: hdmi1
311 updateState(ACTIVE_APP, UnDefType.UNDEF);
314 logger.debug("Unknown app_id: {}, name_space: {}",
315 polledApp.getItem().getValue().getAppId(),
316 polledApp.getItem().getValue().getNameSpace());
319 } catch (VizioException e) {
320 logger.debug("Unable to retrieve Vizio TV current running app. Exception: {}", e.getMessage(), e);
321 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
322 "@text/offline.communication-error-get-app");
330 * Start the job to periodically retrieve various metadata from the Vizio TV every 10 minutes
332 private void startPeriodicRefresh() {
333 ScheduledFuture<?> metadataRefreshJob = this.metadataRefreshJob;
334 if (metadataRefreshJob == null || metadataRefreshJob.isCancelled()) {
335 this.metadataRefreshJob = scheduler.scheduleWithFixedDelay(this::refreshVizioMetadata, 1, 600,
341 * Update source list (hashes) and other metadata from the Vizio TV
343 private void refreshVizioMetadata() {
344 synchronized (sequenceLock) {
346 InputList inputList = communicator.getSourceInputList();
348 List<StateOption> sourceListOptions = new ArrayList<>();
349 inputList.getItems().forEach(source -> {
350 sourceListOptions.add(new StateOption(source.getName(), source.getValue().getName()));
353 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), SOURCE),
355 } catch (VizioException e) {
356 logger.debug("Unable to retrieve the Vizio TV input list. Exception: {}", e.getMessage(), e);
362 public void dispose() {
363 ScheduledFuture<?> refreshJob = this.refreshJob;
364 if (refreshJob != null) {
365 refreshJob.cancel(true);
366 this.refreshJob = null;
369 ScheduledFuture<?> metadataRefreshJob = this.metadataRefreshJob;
370 if (metadataRefreshJob != null) {
371 metadataRefreshJob.cancel(true);
372 this.metadataRefreshJob = null;
376 HttpClient localHttpClient = this.httpClient;
377 if (localHttpClient != null) {
378 localHttpClient.stop();
380 this.httpClient = null;
381 } catch (Exception e) {
382 logger.debug("Unable to stop Vizio httpClient. Exception: {}", e.getMessage(), e);
387 public void handleCommand(ChannelUID channelUID, Command command) {
388 if (command instanceof RefreshType) {
389 logger.debug("Unsupported refresh command: {}", command);
391 switch (channelUID.getId()) {
394 synchronized (sequenceLock) {
396 if (command == OnOffType.ON) {
397 communicator.sendKeyPress(KeyCommand.POWERON.getJson());
400 communicator.sendKeyPress(KeyCommand.POWEROFF.getJson());
403 } catch (VizioException e) {
404 logger.debug("Unable to send power {} command to the Vizio TV, Exception: {}", command,
406 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
407 "@text/offline.communication-error-set-power");
413 synchronized (sequenceLock) {
415 int volume = Integer.parseInt(command.toString());
417 // volume changed again before polling has run, get current volume hash from the TV first
418 if (currentVolumeHash.equals(0L)) {
419 Audio audioSettings = communicator.getCurrentAudioSettings();
421 Optional<ItemAudio> volumeItem = audioSettings.getItems().stream()
422 .filter(i -> VOLUME.equals(i.getCname())).findFirst();
423 if (volumeItem.isPresent()) {
424 currentVolumeHash = volumeItem.get().getHashval();
426 logger.debug("Unable to get current volume hash on the Vizio TV");
430 .changeVolume(String.format(MODIFY_INT_SETTING_JSON, volume, currentVolumeHash));
431 currentVolumeHash = 0L;
432 } catch (VizioException e) {
433 logger.debug("Unable to set volume on the Vizio TV, command volume: {}, Exception: {}",
434 command, e.getMessage());
435 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
436 "@text/offline.communication-error-set-volume");
437 } catch (NumberFormatException e) {
438 logger.debug("Unable to parse command volume value {} as int", command);
444 synchronized (sequenceLock) {
446 if (command == OnOffType.ON && !currentMute) {
447 communicator.sendKeyPress(KeyCommand.MUTETOGGLE.getJson());
449 } else if (command == OnOffType.OFF && currentMute) {
450 communicator.sendKeyPress(KeyCommand.MUTETOGGLE.getJson());
453 } catch (VizioException e) {
454 logger.debug("Unable to send mute {} command to the Vizio TV, Exception: {}", command,
456 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
457 "@text/offline.communication-error-set-mute");
463 synchronized (sequenceLock) {
465 // if input changed again before polling has run, get current input hash from the TV
467 if (currentInputHash.equals(0L)) {
468 CurrentInput polledInput = communicator.getCurrentInput();
469 if (!polledInput.getItems().isEmpty()) {
470 currentInputHash = polledInput.getItems().get(0).getHashval();
474 .changeInput(String.format(MODIFY_STRING_SETTING_JSON, command, currentInputHash));
475 currentInputHash = 0L;
476 } catch (VizioException e) {
477 logger.debug("Unable to set current source on the Vizio TV, source: {}, Exception: {}",
478 command, e.getMessage());
479 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
480 "@text/offline.communication-error-set-source");
486 synchronized (sequenceLock) {
488 Optional<VizioApp> selectedApp = userConfigApps.stream()
489 .filter(a -> command.toString().equals(a.getName())).findFirst();
491 if (selectedApp.isPresent()) {
492 communicator.launchApp(selectedApp.get().getConfig());
494 logger.debug("Unknown app name: '{}', check that it exists in App List configuration",
497 } catch (VizioException e) {
498 logger.debug("Unable to launch app name: '{}' on the Vizio TV, Exception: {}", command,
500 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
501 "@text/offline.communication-error-launch-app");
507 synchronized (sequenceLock) {
509 handleControlCommand(command);
510 } catch (VizioException e) {
511 logger.debug("Unable to send control command: '{}' to the Vizio TV, Exception: {}", command,
513 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
514 "@text/offline.communication-error-send-cmd");
519 synchronized (sequenceLock) {
521 KeyCommand keyCommand = KeyCommand.valueOf(command.toString().toUpperCase(Locale.ENGLISH));
522 communicator.sendKeyPress(keyCommand.getJson());
523 } catch (IllegalArgumentException | VizioException e) {
524 logger.debug("Unable to send keypress to the Vizio TV, key: {}, Exception: {}", command,
526 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
527 "@text/offline.communication-error-send-key");
532 logger.warn("Unknown channel: '{}'", channelUID.getId());
538 private void handleControlCommand(Command command) throws VizioException {
539 if (command instanceof PlayPauseType) {
540 if (command == PlayPauseType.PLAY) {
541 communicator.sendKeyPress(KeyCommand.PLAY.getJson());
542 } else if (command == PlayPauseType.PAUSE) {
543 communicator.sendKeyPress(KeyCommand.PAUSE.getJson());
545 } else if (command instanceof NextPreviousType) {
546 if (command == NextPreviousType.NEXT) {
547 communicator.sendKeyPress(KeyCommand.RIGHT.getJson());
548 } else if (command == NextPreviousType.PREVIOUS) {
549 communicator.sendKeyPress(KeyCommand.LEFT.getJson());
551 } else if (command instanceof RewindFastforwardType) {
552 if (command == RewindFastforwardType.FASTFORWARD) {
553 communicator.sendKeyPress(KeyCommand.SEEKFWD.getJson());
554 } else if (command == RewindFastforwardType.REWIND) {
555 communicator.sendKeyPress(KeyCommand.SEEKBACK.getJson());
558 logger.warn("Unknown control command: {}", command);
563 public boolean isLinked(String channelName) {
564 Channel channel = this.thing.getChannel(channelName);
565 if (channel != null) {
566 return isLinked(channel.getUID());
572 // The remaining methods are used by the console when obtaining the auth token from the TV.
573 public int startPairing(String deviceName) throws VizioException {
574 Random rng = new Random();
575 pairingDeviceId = rng.nextInt(100000);
577 pairingToken = communicator.startPairing(deviceName, pairingDeviceId).getItem().getPairingReqToken();
582 public String submitPairingCode(String pairingCode) throws IllegalStateException, VizioException {
583 if (pairingDeviceId < 0 || pairingToken < 0) {
584 throw new IllegalStateException();
587 return communicator.submitPairingCode(pairingDeviceId, pairingCode, pairingToken).getItem().getAuthToken();
590 public void saveAuthToken(String authToken) {
591 pairingDeviceId = -1;
594 // Store the auth token in the configuration and restart the thing
595 Configuration configuration = this.getConfig();
596 configuration.put(PROPERTY_AUTH_TOKEN, authToken);
597 this.updateConfiguration(configuration);
598 this.thingUpdated(this.getThing());