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.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.util.ssl.SslContextFactory;
29 import org.openhab.binding.vizio.internal.VizioConfiguration;
30 import org.openhab.binding.vizio.internal.VizioException;
31 import org.openhab.binding.vizio.internal.VizioStateDescriptionOptionProvider;
32 import org.openhab.binding.vizio.internal.communication.VizioCommunicator;
33 import org.openhab.binding.vizio.internal.dto.app.CurrentApp;
34 import org.openhab.binding.vizio.internal.dto.applist.VizioApp;
35 import org.openhab.binding.vizio.internal.dto.applist.VizioApps;
36 import org.openhab.binding.vizio.internal.dto.audio.Audio;
37 import org.openhab.binding.vizio.internal.dto.audio.ItemAudio;
38 import org.openhab.binding.vizio.internal.dto.input.CurrentInput;
39 import org.openhab.binding.vizio.internal.dto.inputlist.InputList;
40 import org.openhab.binding.vizio.internal.dto.power.PowerMode;
41 import org.openhab.binding.vizio.internal.enums.KeyCommand;
42 import org.openhab.core.config.core.Configuration;
43 import org.openhab.core.io.net.http.HttpClientFactory;
44 import org.openhab.core.library.types.NextPreviousType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PercentType;
47 import org.openhab.core.library.types.PlayPauseType;
48 import org.openhab.core.library.types.RewindFastforwardType;
49 import org.openhab.core.library.types.StringType;
50 import org.openhab.core.thing.Channel;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.util.ThingWebClientUtil;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.StateOption;
60 import org.openhab.core.types.UnDefType;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
64 import com.google.gson.Gson;
65 import com.google.gson.JsonSyntaxException;
68 * The {@link VizioHandler} is responsible for handling commands, which are
69 * sent to one of the channels.
71 * @author Michael Lobstein - Initial contribution
74 public class VizioHandler extends BaseThingHandler {
75 private final Logger logger = LoggerFactory.getLogger(VizioHandler.class);
76 private final HttpClientFactory httpClientFactory;
77 private @Nullable HttpClient httpClient;
78 private final VizioStateDescriptionOptionProvider stateDescriptionProvider;
79 private final String dbAppsJson;
81 private @Nullable ScheduledFuture<?> refreshJob;
82 private @Nullable ScheduledFuture<?> metadataRefreshJob;
84 private VizioCommunicator communicator;
85 private List<VizioApp> userConfigApps = new ArrayList<VizioApp>();
86 private Object sequenceLock = new Object();
88 private int pairingDeviceId = -1;
89 private int pairingToken = -1;
90 private Long currentInputHash = 0L;
91 private Long currentVolumeHash = 0L;
92 private String currentApp = EMPTY;
93 private String currentInput = EMPTY;
94 private boolean currentMute = false;
95 private int currentVolume = -1;
96 private boolean powerOn = false;
97 private boolean debounce = true;
99 public VizioHandler(Thing thing, HttpClientFactory httpClientFactory,
100 VizioStateDescriptionOptionProvider stateDescriptionProvider, String vizioAppsJson) {
102 this.httpClientFactory = httpClientFactory;
103 this.stateDescriptionProvider = stateDescriptionProvider;
104 this.dbAppsJson = vizioAppsJson;
105 this.communicator = new VizioCommunicator(httpClientFactory.getCommonHttpClient(), EMPTY, -1, EMPTY);
109 public void initialize() {
110 logger.debug("Initializing Vizio handler");
111 final Gson gson = new Gson();
112 VizioConfiguration config = getConfigAs(VizioConfiguration.class);
115 String host = config.hostName;
116 final @Nullable String authToken = config.authToken;
118 String appListJson = config.appListJson;
120 if (host == null || host.isEmpty()) {
121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
122 "@text/offline.configuration-error-hostname");
124 } else if (host.contains(":")) {
126 host = "[" + host + "]";
129 final String httpClientName = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
131 httpClient = httpClientFactory.createHttpClient(httpClientName, new SslContextFactory.Client(true));
132 final HttpClient localHttpClient = this.httpClient;
133 if (localHttpClient != null) {
134 localHttpClient.start();
135 this.communicator = new VizioCommunicator(localHttpClient, host, config.port,
136 authToken != null ? authToken : EMPTY);
138 } catch (Exception e) {
140 "Long running HttpClient for Vizio handler {} cannot be started. Creating Handler failed. Exception: {}",
141 httpClientName, e.getMessage(), e);
142 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
146 if (authToken == null) {
147 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
148 "@text/offline.configuration-error-authtoken");
152 // if app list is not supplied in thing configuration, populate it from the json db
153 if (appListJson == null) {
154 appListJson = dbAppsJson;
156 // Update thing configuration (persistent) - store app list from db into thing so the user can update it
157 Configuration configuration = this.getConfig();
158 configuration.put(PROPERTY_APP_LIST_JSON, appListJson);
159 this.updateConfiguration(configuration);
163 VizioApps appsFromJson = gson.fromJson(appListJson, VizioApps.class);
164 if (appsFromJson != null && !appsFromJson.getApps().isEmpty()) {
165 userConfigApps = appsFromJson.getApps();
167 List<StateOption> appListOptions = new ArrayList<>();
168 userConfigApps.forEach(app -> {
169 appListOptions.add(new StateOption(app.getName(), app.getName()));
172 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), ACTIVE_APP),
175 } catch (JsonSyntaxException e) {
176 logger.debug("Invalid App List Configuration in thing configuration. Exception: {}", e.getMessage(), e);
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
178 "@text/offline.configuration-error-applist");
182 updateStatus(ThingStatus.UNKNOWN);
184 startVizioStateRefresh();
185 startPeriodicRefresh();
189 * Start the job that queries the Vizio TV every 10 seconds to get its current status
191 private void startVizioStateRefresh() {
192 ScheduledFuture<?> refreshJob = this.refreshJob;
193 if (refreshJob == null || refreshJob.isCancelled()) {
194 this.refreshJob = scheduler.scheduleWithFixedDelay(this::refreshVizioState, 5, 10, TimeUnit.SECONDS);
199 * Get current status from the Vizio TV and update the channels
201 private void refreshVizioState() {
202 synchronized (sequenceLock) {
204 PowerMode polledPowerMode = communicator.getPowerMode();
206 if (debounce && !polledPowerMode.getItems().isEmpty()) {
207 int powerMode = polledPowerMode.getItems().get(0).getValue();
208 if (powerMode == 1) {
210 updateState(POWER, OnOffType.ON);
211 } else if (powerMode == 0) {
213 updateState(POWER, OnOffType.OFF);
215 logger.debug("Unknown power mode {}, for response object: {}", powerMode, polledPowerMode);
218 updateStatus(ThingStatus.ONLINE);
219 } catch (VizioException e) {
220 logger.debug("Unable to retrieve Vizio TV power mode info. Exception: {}", e.getMessage(), e);
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
222 "@text/offline.communication-error-get-power");
225 if (powerOn && (isLinked(VOLUME) || isLinked(MUTE))) {
227 Audio audioSettings = communicator.getCurrentAudioSettings();
229 Optional<ItemAudio> volumeItem = audioSettings.getItems().stream()
230 .filter(i -> VOLUME.equals(i.getCname())).findFirst();
231 if (debounce && volumeItem.isPresent()) {
232 currentVolumeHash = volumeItem.get().getHashval();
235 int polledVolume = Integer.parseInt(volumeItem.get().getValue());
236 if (polledVolume != currentVolume) {
237 currentVolume = polledVolume;
238 updateState(VOLUME, new PercentType(BigDecimal.valueOf(currentVolume)));
240 } catch (NumberFormatException e) {
241 logger.debug("Unable to parse volume value {} as int", volumeItem.get().getValue());
245 Optional<ItemAudio> muteItem = audioSettings.getItems().stream()
246 .filter(i -> MUTE.equals(i.getCname())).findFirst();
247 if (debounce && muteItem.isPresent()) {
248 String polledMute = muteItem.get().getValue().toUpperCase(Locale.ENGLISH);
250 if (ON.equals(polledMute) || OFF.equals(polledMute)) {
251 if (ON.equals(polledMute) && !currentMute) {
252 updateState(MUTE, OnOffType.ON);
254 } else if (OFF.equals(polledMute) && currentMute) {
255 updateState(MUTE, OnOffType.OFF);
259 logger.debug("Unknown mute mode {}, for response object: {}", polledMute, audioSettings);
262 } catch (VizioException e) {
263 logger.debug("Unable to retrieve Vizio TV current audio settings. Exception: {}", e.getMessage(),
265 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
266 "@text/offline.communication-error-get-audio");
270 if (powerOn && isLinked(SOURCE)) {
272 CurrentInput polledInputState = communicator.getCurrentInput();
274 if (debounce && !polledInputState.getItems().isEmpty()
275 && !currentInput.equals(polledInputState.getItems().get(0).getValue())) {
276 currentInput = polledInputState.getItems().get(0).getValue();
277 currentInputHash = polledInputState.getItems().get(0).getHashval();
278 updateState(SOURCE, new StringType(currentInput));
280 } catch (VizioException e) {
281 logger.debug("Unable to retrieve Vizio TV current input. Exception: {}", e.getMessage(), e);
282 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
283 "@text/offline.communication-error-get-input");
287 if (powerOn && isLinked(ACTIVE_APP)) {
290 CurrentApp polledApp = communicator.getCurrentApp();
291 Optional<VizioApp> currentAppData = userConfigApps.stream()
292 .filter(a -> a.getConfig().getAppId().equals(polledApp.getItem().getValue().getAppId())
293 && a.getConfig().getNameSpace()
294 .equals(polledApp.getItem().getValue().getNameSpace()))
297 if (currentAppData.isPresent()) {
298 if (!currentApp.equals(currentAppData.get().getName())) {
299 currentApp = currentAppData.get().getName();
300 updateState(ACTIVE_APP, new StringType(currentApp));
305 int appId = Integer.parseInt(polledApp.getItem().getValue().getAppId());
306 updateState(ACTIVE_APP, new StringType(String.format(UNKNOWN_APP_STR, appId,
307 polledApp.getItem().getValue().getNameSpace())));
308 } catch (NumberFormatException nfe) {
309 // Non-numeric appId received, eg: hdmi1
310 updateState(ACTIVE_APP, UnDefType.UNDEF);
313 logger.debug("Unknown app_id: {}, name_space: {}",
314 polledApp.getItem().getValue().getAppId(),
315 polledApp.getItem().getValue().getNameSpace());
318 } catch (VizioException e) {
319 logger.debug("Unable to retrieve Vizio TV current running app. Exception: {}", e.getMessage(), e);
320 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
321 "@text/offline.communication-error-get-app");
329 * Start the job to periodically retrieve various metadata from the Vizio TV every 10 minutes
331 private void startPeriodicRefresh() {
332 ScheduledFuture<?> metadataRefreshJob = this.metadataRefreshJob;
333 if (metadataRefreshJob == null || metadataRefreshJob.isCancelled()) {
334 this.metadataRefreshJob = scheduler.scheduleWithFixedDelay(this::refreshVizioMetadata, 1, 600,
340 * Update source list (hashes) and other metadata from the Vizio TV
342 private void refreshVizioMetadata() {
343 synchronized (sequenceLock) {
345 InputList inputList = communicator.getSourceInputList();
347 List<StateOption> sourceListOptions = new ArrayList<>();
348 inputList.getItems().forEach(source -> {
349 sourceListOptions.add(new StateOption(source.getName(), source.getValue().getName()));
352 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), SOURCE),
354 } catch (VizioException e) {
355 logger.debug("Unable to retrieve the Vizio TV input list. Exception: {}", e.getMessage(), e);
361 public void dispose() {
362 ScheduledFuture<?> refreshJob = this.refreshJob;
363 if (refreshJob != null) {
364 refreshJob.cancel(true);
365 this.refreshJob = null;
368 ScheduledFuture<?> metadataRefreshJob = this.metadataRefreshJob;
369 if (metadataRefreshJob != null) {
370 metadataRefreshJob.cancel(true);
371 this.metadataRefreshJob = null;
375 HttpClient localHttpClient = this.httpClient;
376 if (localHttpClient != null) {
377 localHttpClient.stop();
379 this.httpClient = null;
380 } catch (Exception e) {
381 logger.debug("Unable to stop Vizio httpClient. Exception: {}", e.getMessage(), e);
386 public void handleCommand(ChannelUID channelUID, Command command) {
387 if (command instanceof RefreshType) {
388 logger.debug("Unsupported refresh command: {}", command);
390 switch (channelUID.getId()) {
393 synchronized (sequenceLock) {
395 if (command == OnOffType.ON) {
396 communicator.sendKeyPress(KeyCommand.POWERON.getJson());
399 communicator.sendKeyPress(KeyCommand.POWEROFF.getJson());
402 } catch (VizioException e) {
403 logger.debug("Unable to send power {} command to the Vizio TV, Exception: {}", command,
405 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
406 "@text/offline.communication-error-set-power");
412 synchronized (sequenceLock) {
414 int volume = Integer.parseInt(command.toString());
416 // volume changed again before polling has run, get current volume hash from the TV first
417 if (currentVolumeHash.equals(0L)) {
418 Audio audioSettings = communicator.getCurrentAudioSettings();
420 Optional<ItemAudio> volumeItem = audioSettings.getItems().stream()
421 .filter(i -> VOLUME.equals(i.getCname())).findFirst();
422 if (volumeItem.isPresent()) {
423 currentVolumeHash = volumeItem.get().getHashval();
425 logger.debug("Unable to get current volume hash on the Vizio TV");
429 .changeVolume(String.format(MODIFY_INT_SETTING_JSON, volume, currentVolumeHash));
430 currentVolumeHash = 0L;
431 } catch (VizioException e) {
432 logger.debug("Unable to set volume on the Vizio TV, command volume: {}, Exception: {}",
433 command, e.getMessage());
434 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
435 "@text/offline.communication-error-set-volume");
436 } catch (NumberFormatException e) {
437 logger.debug("Unable to parse command volume value {} as int", command);
443 synchronized (sequenceLock) {
445 if (command == OnOffType.ON && !currentMute) {
446 communicator.sendKeyPress(KeyCommand.MUTETOGGLE.getJson());
448 } else if (command == OnOffType.OFF && currentMute) {
449 communicator.sendKeyPress(KeyCommand.MUTETOGGLE.getJson());
452 } catch (VizioException e) {
453 logger.debug("Unable to send mute {} command to the Vizio TV, Exception: {}", command,
455 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
456 "@text/offline.communication-error-set-mute");
462 synchronized (sequenceLock) {
464 // if input changed again before polling has run, get current input hash from the TV
466 if (currentInputHash.equals(0L)) {
467 CurrentInput polledInput = communicator.getCurrentInput();
468 if (!polledInput.getItems().isEmpty()) {
469 currentInputHash = polledInput.getItems().get(0).getHashval();
473 .changeInput(String.format(MODIFY_STRING_SETTING_JSON, command, currentInputHash));
474 currentInputHash = 0L;
475 } catch (VizioException e) {
476 logger.debug("Unable to set current source on the Vizio TV, source: {}, Exception: {}",
477 command, e.getMessage());
478 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
479 "@text/offline.communication-error-set-source");
485 synchronized (sequenceLock) {
487 Optional<VizioApp> selectedApp = userConfigApps.stream()
488 .filter(a -> command.toString().equals(a.getName())).findFirst();
490 if (selectedApp.isPresent()) {
491 communicator.launchApp(selectedApp.get().getConfig());
493 logger.debug("Unknown app name: '{}', check that it exists in App List configuration",
496 } catch (VizioException e) {
497 logger.debug("Unable to launch app name: '{}' on the Vizio TV, Exception: {}", command,
499 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
500 "@text/offline.communication-error-launch-app");
506 synchronized (sequenceLock) {
508 handleControlCommand(command);
509 } catch (VizioException e) {
510 logger.debug("Unable to send control command: '{}' to the Vizio TV, Exception: {}", command,
512 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
513 "@text/offline.communication-error-send-cmd");
518 synchronized (sequenceLock) {
520 KeyCommand keyCommand = KeyCommand.valueOf(command.toString().toUpperCase(Locale.ENGLISH));
521 communicator.sendKeyPress(keyCommand.getJson());
522 } catch (IllegalArgumentException | VizioException e) {
523 logger.debug("Unable to send keypress to the Vizio TV, key: {}, Exception: {}", command,
525 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
526 "@text/offline.communication-error-send-key");
531 logger.warn("Unknown channel: '{}'", channelUID.getId());
537 private void handleControlCommand(Command command) throws VizioException {
538 if (command instanceof PlayPauseType) {
539 if (command == PlayPauseType.PLAY) {
540 communicator.sendKeyPress(KeyCommand.PLAY.getJson());
541 } else if (command == PlayPauseType.PAUSE) {
542 communicator.sendKeyPress(KeyCommand.PAUSE.getJson());
544 } else if (command instanceof NextPreviousType) {
545 if (command == NextPreviousType.NEXT) {
546 communicator.sendKeyPress(KeyCommand.RIGHT.getJson());
547 } else if (command == NextPreviousType.PREVIOUS) {
548 communicator.sendKeyPress(KeyCommand.LEFT.getJson());
550 } else if (command instanceof RewindFastforwardType) {
551 if (command == RewindFastforwardType.FASTFORWARD) {
552 communicator.sendKeyPress(KeyCommand.SEEKFWD.getJson());
553 } else if (command == RewindFastforwardType.REWIND) {
554 communicator.sendKeyPress(KeyCommand.SEEKBACK.getJson());
557 logger.warn("Unknown control command: {}", command);
562 public boolean isLinked(String channelName) {
563 Channel channel = this.thing.getChannel(channelName);
564 if (channel != null) {
565 return isLinked(channel.getUID());
571 // The remaining methods are used by the console when obtaining the auth token from the TV.
572 public void saveAuthToken(String authToken) {
573 // Store the auth token in the configuration and restart the thing
574 Configuration configuration = this.getConfig();
575 configuration.put(PROPERTY_AUTH_TOKEN, authToken);
576 this.updateConfiguration(configuration);
577 this.thingUpdated(this.getThing());
580 public int getPairingDeviceId() {
581 return pairingDeviceId;
584 public void setPairingDeviceId(int pairingDeviceId) {
585 this.pairingDeviceId = pairingDeviceId;
588 public int getPairingToken() {
592 public void setPairingToken(int pairingToken) {
593 this.pairingToken = pairingToken;