2 * Copyright (c) 2010-2020 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.amazonechocontrol.internal.handler;
15 import static org.openhab.binding.amazonechocontrol.internal.AmazonEchoControlBindingConstants.*;
17 import java.io.IOException;
18 import java.net.URISyntaxException;
19 import java.time.Instant;
20 import java.time.ZoneId;
21 import java.time.ZonedDateTime;
22 import java.time.format.DateTimeFormatter;
23 import java.time.temporal.ChronoUnit;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.stream.Collectors;
28 import java.util.stream.Stream;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.amazonechocontrol.internal.Connection;
33 import org.openhab.binding.amazonechocontrol.internal.ConnectionException;
34 import org.openhab.binding.amazonechocontrol.internal.HttpException;
35 import org.openhab.binding.amazonechocontrol.internal.channelhandler.ChannelHandler;
36 import org.openhab.binding.amazonechocontrol.internal.channelhandler.ChannelHandlerAnnouncement;
37 import org.openhab.binding.amazonechocontrol.internal.channelhandler.IEchoThingHandler;
38 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity;
39 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonActivities.Activity.Description;
40 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonAscendingAlarm.AscendingAlarmModel;
41 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
42 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates.BluetoothState;
43 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates.PairedDevice;
44 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonCommandPayloadPushNotificationChange;
45 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonCommandPayloadPushVolumeChange;
46 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDeviceNotificationState.DeviceNotificationState;
47 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
48 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonEqualizer;
49 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState;
50 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMediaState.QueueEntry;
51 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
52 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationResponse;
53 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
54 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState;
55 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState.PlayerInfo;
56 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState.PlayerInfo.InfoText;
57 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState.PlayerInfo.MainArt;
58 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState.PlayerInfo.Progress;
59 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState.PlayerInfo.Provider;
60 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlayerState.PlayerInfo.Volume;
61 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
62 import org.openhab.core.library.types.DateTimeType;
63 import org.openhab.core.library.types.DecimalType;
64 import org.openhab.core.library.types.IncreaseDecreaseType;
65 import org.openhab.core.library.types.NextPreviousType;
66 import org.openhab.core.library.types.OnOffType;
67 import org.openhab.core.library.types.PercentType;
68 import org.openhab.core.library.types.PlayPauseType;
69 import org.openhab.core.library.types.QuantityType;
70 import org.openhab.core.library.types.RewindFastforwardType;
71 import org.openhab.core.library.types.StringType;
72 import org.openhab.core.library.unit.Units;
73 import org.openhab.core.thing.Bridge;
74 import org.openhab.core.thing.ChannelUID;
75 import org.openhab.core.thing.Thing;
76 import org.openhab.core.thing.ThingStatus;
77 import org.openhab.core.thing.ThingUID;
78 import org.openhab.core.thing.binding.BaseThingHandler;
79 import org.openhab.core.types.Command;
80 import org.openhab.core.types.RefreshType;
81 import org.openhab.core.types.State;
82 import org.openhab.core.types.UnDefType;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
86 import com.google.gson.Gson;
89 * The {@link EchoHandler} is responsible for the handling of the echo device
91 * @author Michael Geramb - Initial contribution
94 public class EchoHandler extends BaseThingHandler implements IEchoThingHandler {
95 private final Logger logger = LoggerFactory.getLogger(EchoHandler.class);
97 private @Nullable Device device;
98 private Set<String> capabilities = new HashSet<>();
99 private @Nullable AccountHandler account;
100 private @Nullable ScheduledFuture<?> updateStateJob;
101 private @Nullable ScheduledFuture<?> updateProgressJob;
102 private Object progressLock = new Object();
103 private @Nullable String wakeWord;
104 private @Nullable String lastKnownRadioStationId;
105 private @Nullable String lastKnownBluetoothMAC;
106 private @Nullable String lastKnownAmazonMusicId;
107 private String musicProviderId = "TUNEIN";
108 private boolean isPlaying = false;
109 private boolean isPaused = false;
110 private int lastKnownVolume = 25;
111 private int textToSpeechVolume = 0;
112 private @Nullable JsonEqualizer lastKnownEqualizer = null;
113 private @Nullable BluetoothState bluetoothState;
114 private boolean disableUpdate = false;
115 private boolean updateRemind = true;
116 private boolean updateTextToSpeech = true;
117 private boolean updateAlarm = true;
118 private boolean updateRoutine = true;
119 private boolean updatePlayMusicVoiceCommand = true;
120 private boolean updateStartCommand = true;
121 private @Nullable Integer notificationVolumeLevel;
122 private @Nullable Boolean ascendingAlarm;
123 private @Nullable JsonPlaylists playLists;
124 private @Nullable JsonNotificationSound @Nullable [] alarmSounds;
125 private @Nullable List<JsonMusicProvider> musicProviders;
126 private List<ChannelHandler> channelHandlers = new ArrayList<>();
128 private @Nullable JsonNotificationResponse currentNotification;
129 private @Nullable ScheduledFuture<?> currentNotifcationUpdateTimer;
131 long mediaProgressMs;
133 String lastSpokenText = "";
135 public EchoHandler(Thing thing, Gson gson) {
138 channelHandlers.add(new ChannelHandlerAnnouncement(this, this.gson));
142 public void initialize() {
143 logger.debug("Amazon Echo Control Binding initialized");
144 Bridge bridge = this.getBridge();
145 if (bridge != null) {
146 AccountHandler account = (AccountHandler) bridge.getHandler();
147 if (account != null) {
148 setDeviceAndUpdateThingState(account, this.device, null);
149 account.addEchoHandler(this);
154 public boolean setDeviceAndUpdateThingState(AccountHandler accountHandler, @Nullable Device device,
155 @Nullable String wakeWord) {
156 this.account = accountHandler;
157 if (wakeWord != null) {
158 this.wakeWord = wakeWord;
160 if (device == null) {
161 updateStatus(ThingStatus.UNKNOWN);
164 this.device = device;
165 String[] capabilities = device.capabilities;
166 if (capabilities != null) {
167 this.capabilities = Stream.of(capabilities).filter(Objects::nonNull).collect(Collectors.toSet());
169 if (!device.online) {
170 updateStatus(ThingStatus.OFFLINE);
173 updateStatus(ThingStatus.ONLINE);
178 public void dispose() {
179 stopCurrentNotification();
180 ScheduledFuture<?> updateStateJob = this.updateStateJob;
181 this.updateStateJob = null;
182 if (updateStateJob != null) {
183 this.disableUpdate = false;
184 updateStateJob.cancel(false);
190 private void stopProgressTimer() {
191 ScheduledFuture<?> updateProgressJob = this.updateProgressJob;
192 this.updateProgressJob = null;
193 if (updateProgressJob != null) {
194 updateProgressJob.cancel(false);
198 public @Nullable BluetoothState findBluetoothState() {
199 return this.bluetoothState;
202 public @Nullable JsonPlaylists findPlaylists() {
203 return this.playLists;
206 public @Nullable JsonNotificationSound @Nullable [] findAlarmSounds() {
207 return this.alarmSounds;
210 public @Nullable List<JsonMusicProvider> findMusicProviders() {
211 return this.musicProviders;
214 private @Nullable Connection findConnection() {
215 AccountHandler accountHandler = this.account;
216 if (accountHandler != null) {
217 return accountHandler.findConnection();
222 public @Nullable AccountHandler findAccount() {
226 public @Nullable Device findDevice() {
230 public String findSerialNumber() {
231 String id = (String) getConfig().get(DEVICE_PROPERTY_SERIAL_NUMBER);
239 public void handleCommand(ChannelUID channelUID, Command command) {
241 logger.trace("Command '{}' received for channel '{}'", command, channelUID);
242 int waitForUpdate = 1000;
243 boolean needBluetoothRefresh = false;
244 String lastKnownBluetoothMAC = this.lastKnownBluetoothMAC;
246 ScheduledFuture<?> updateStateJob = this.updateStateJob;
247 this.updateStateJob = null;
248 if (updateStateJob != null) {
249 this.disableUpdate = false;
250 updateStateJob.cancel(false);
252 AccountHandler account = this.account;
253 if (account == null) {
256 Connection connection = account.findConnection();
257 if (connection == null) {
260 Device device = this.device;
261 if (device == null) {
265 String channelId = channelUID.getId();
266 for (ChannelHandler channelHandler : channelHandlers) {
267 if (channelHandler.tryHandleCommand(device, connection, channelId, command)) {
273 if (channelId.equals(CHANNEL_PLAYER)) {
274 if (command == PlayPauseType.PAUSE || command == OnOffType.OFF) {
275 connection.command(device, "{\"type\":\"PauseCommand\"}");
276 } else if (command == PlayPauseType.PLAY || command == OnOffType.ON) {
278 connection.command(device, "{\"type\":\"PlayCommand\"}");
280 connection.playMusicVoiceCommand(device, this.musicProviderId, "!");
281 waitForUpdate = 3000;
283 } else if (command == NextPreviousType.NEXT) {
284 connection.command(device, "{\"type\":\"NextCommand\"}");
285 } else if (command == NextPreviousType.PREVIOUS) {
286 connection.command(device, "{\"type\":\"PreviousCommand\"}");
287 } else if (command == RewindFastforwardType.FASTFORWARD) {
288 connection.command(device, "{\"type\":\"ForwardCommand\"}");
289 } else if (command == RewindFastforwardType.REWIND) {
290 connection.command(device, "{\"type\":\"RewindCommand\"}");
293 // Notification commands
294 if (channelId.equals(CHANNEL_NOTIFICATION_VOLUME)) {
295 if (command instanceof PercentType) {
296 int volume = ((PercentType) command).intValue();
297 connection.notificationVolume(device, volume);
298 this.notificationVolumeLevel = volume;
300 account.forceCheckData();
303 if (channelId.equals(CHANNEL_ASCENDING_ALARM)) {
304 if (command == OnOffType.OFF) {
305 connection.ascendingAlarm(device, false);
306 this.ascendingAlarm = false;
308 account.forceCheckData();
310 if (command == OnOffType.ON) {
311 connection.ascendingAlarm(device, true);
312 this.ascendingAlarm = true;
314 account.forceCheckData();
317 // Media progress commands
318 Long mediaPosition = null;
319 if (channelId.equals(CHANNEL_MEDIA_PROGRESS)) {
320 if (command instanceof PercentType) {
321 PercentType value = (PercentType) command;
322 int percent = value.intValue();
323 mediaPosition = Math.round((mediaLengthMs / 1000d) * (percent / 100d));
326 if (channelId.equals(CHANNEL_MEDIA_PROGRESS_TIME)) {
327 if (command instanceof DecimalType) {
328 DecimalType value = (DecimalType) command;
329 mediaPosition = value.longValue();
331 if (command instanceof QuantityType<?>) {
332 QuantityType<?> value = (QuantityType<?>) command;
334 QuantityType<?> seconds = value.toUnit(Units.SECOND);
335 if (seconds != null) {
336 mediaPosition = seconds.longValue();
340 if (mediaPosition != null) {
342 synchronized (progressLock) {
343 String seekCommand = "{\"type\":\"SeekCommand\",\"mediaPosition\":" + mediaPosition
344 + ",\"contentFocusClientId\":null}";
345 connection.command(device, seekCommand);
346 connection.command(device, seekCommand); // Must be sent twice, the first one is ignored sometimes
347 this.mediaProgressMs = mediaPosition * 1000;
348 mediaStartMs = System.currentTimeMillis() - this.mediaProgressMs;
349 updateMediaProgress(false);
353 if (channelId.equals(CHANNEL_VOLUME)) {
354 Integer volume = null;
355 if (command instanceof PercentType) {
356 PercentType value = (PercentType) command;
357 volume = value.intValue();
358 } else if (command == OnOffType.OFF) {
360 } else if (command == OnOffType.ON) {
361 volume = lastKnownVolume;
362 } else if (command == IncreaseDecreaseType.INCREASE) {
363 if (lastKnownVolume < 100) {
365 volume = lastKnownVolume;
367 } else if (command == IncreaseDecreaseType.DECREASE) {
368 if (lastKnownVolume > 0) {
370 volume = lastKnownVolume;
373 if (volume != null) {
374 if ("WHA".equals(device.deviceFamily)) {
375 connection.command(device, "{\"type\":\"VolumeLevelCommand\",\"volumeLevel\":" + volume
376 + ",\"contentFocusClientId\":\"Default\"}");
378 connection.volume(device, volume);
380 lastKnownVolume = volume;
381 updateState(CHANNEL_VOLUME, new PercentType(lastKnownVolume));
385 // equalizer commands
386 if (channelId.equals(CHANNEL_EQUALIZER_BASS) || channelId.equals(CHANNEL_EQUALIZER_MIDRANGE)
387 || channelId.equals(CHANNEL_EQUALIZER_TREBLE)) {
388 if (handleEqualizerCommands(channelId, command, connection, device)) {
394 if (channelId.equals(CHANNEL_SHUFFLE)) {
395 if (command instanceof OnOffType) {
396 OnOffType value = (OnOffType) command;
398 connection.command(device, "{\"type\":\"ShuffleCommand\",\"shuffle\":\""
399 + (value == OnOffType.ON ? "true" : "false") + "\"}");
403 // play music command
404 if (channelId.equals(CHANNEL_MUSIC_PROVIDER_ID)) {
405 if (command instanceof StringType) {
407 String musicProviderId = command.toFullString();
408 if (!musicProviderId.equals(this.musicProviderId)) {
409 this.musicProviderId = musicProviderId;
410 if (this.isPlaying) {
411 connection.playMusicVoiceCommand(device, this.musicProviderId, "!");
412 waitForUpdate = 3000;
417 if (channelId.equals(CHANNEL_PLAY_MUSIC_VOICE_COMMAND)) {
418 if (command instanceof StringType) {
419 String voiceCommand = command.toFullString();
420 if (!this.musicProviderId.isEmpty()) {
421 connection.playMusicVoiceCommand(device, this.musicProviderId, voiceCommand);
422 waitForUpdate = 3000;
423 updatePlayMusicVoiceCommand = true;
428 // bluetooth commands
429 if (channelId.equals(CHANNEL_BLUETOOTH_MAC)) {
430 needBluetoothRefresh = true;
431 if (command instanceof StringType) {
432 String address = ((StringType) command).toFullString();
433 if (!address.isEmpty()) {
434 waitForUpdate = 4000;
436 connection.bluetooth(device, address);
439 if (channelId.equals(CHANNEL_BLUETOOTH)) {
440 needBluetoothRefresh = true;
441 if (command == OnOffType.ON) {
442 waitForUpdate = 4000;
443 String bluetoothId = lastKnownBluetoothMAC;
444 BluetoothState state = bluetoothState;
445 if (state != null && (bluetoothId == null || bluetoothId.isEmpty())) {
446 PairedDevice[] pairedDeviceList = state.pairedDeviceList;
447 if (pairedDeviceList != null) {
448 for (PairedDevice paired : pairedDeviceList) {
449 if (paired == null) {
452 String pairedAddress = paired.address;
453 if (pairedAddress != null && !pairedAddress.isEmpty()) {
454 lastKnownBluetoothMAC = pairedAddress;
460 if (lastKnownBluetoothMAC != null && !lastKnownBluetoothMAC.isEmpty()) {
461 connection.bluetooth(device, lastKnownBluetoothMAC);
463 } else if (command == OnOffType.OFF) {
464 connection.bluetooth(device, null);
467 if (channelId.equals(CHANNEL_BLUETOOTH_DEVICE_NAME)) {
468 needBluetoothRefresh = true;
470 // amazon music commands
471 if (channelId.equals(CHANNEL_AMAZON_MUSIC_TRACK_ID)) {
472 if (command instanceof StringType) {
473 String trackId = command.toFullString();
474 if (!trackId.isEmpty()) {
475 waitForUpdate = 3000;
477 connection.playAmazonMusicTrack(device, trackId);
480 if (channelId.equals(CHANNEL_AMAZON_MUSIC_PLAY_LIST_ID)) {
481 if (command instanceof StringType) {
482 String playListId = command.toFullString();
483 if (!playListId.isEmpty()) {
484 waitForUpdate = 3000;
486 connection.playAmazonMusicPlayList(device, playListId);
489 if (channelId.equals(CHANNEL_AMAZON_MUSIC)) {
490 if (command == OnOffType.ON) {
491 String lastKnownAmazonMusicId = this.lastKnownAmazonMusicId;
492 if (lastKnownAmazonMusicId != null && !lastKnownAmazonMusicId.isEmpty()) {
493 waitForUpdate = 3000;
495 connection.playAmazonMusicTrack(device, lastKnownAmazonMusicId);
496 } else if (command == OnOffType.OFF) {
497 connection.playAmazonMusicTrack(device, "");
502 if (channelId.equals(CHANNEL_RADIO_STATION_ID)) {
503 if (command instanceof StringType) {
504 String stationId = command.toFullString();
505 if (!stationId.isEmpty()) {
506 waitForUpdate = 3000;
508 connection.playRadio(device, stationId);
511 if (channelId.equals(CHANNEL_RADIO)) {
512 if (command == OnOffType.ON) {
513 String lastKnownRadioStationId = this.lastKnownRadioStationId;
514 if (lastKnownRadioStationId != null && !lastKnownRadioStationId.isEmpty()) {
515 waitForUpdate = 3000;
517 connection.playRadio(device, lastKnownRadioStationId);
518 } else if (command == OnOffType.OFF) {
519 connection.playRadio(device, "");
524 if (channelId.equals(CHANNEL_REMIND)) {
525 if (command instanceof StringType) {
526 stopCurrentNotification();
527 String reminder = command.toFullString();
528 if (!reminder.isEmpty()) {
529 waitForUpdate = 3000;
531 currentNotification = connection.notification(device, "Reminder", reminder, null);
532 currentNotifcationUpdateTimer = scheduler.scheduleWithFixedDelay(() -> {
533 updateNotificationTimerState();
534 }, 1, 1, TimeUnit.SECONDS);
538 if (channelId.equals(CHANNEL_PLAY_ALARM_SOUND)) {
539 if (command instanceof StringType) {
540 stopCurrentNotification();
541 String alarmSound = command.toFullString();
542 if (!alarmSound.isEmpty()) {
543 waitForUpdate = 3000;
545 String[] parts = alarmSound.split(":", 2);
546 JsonNotificationSound sound = new JsonNotificationSound();
547 if (parts.length == 2) {
548 sound.providerId = parts[0];
551 sound.providerId = "ECHO";
552 sound.id = alarmSound;
554 currentNotification = connection.notification(device, "Alarm", null, sound);
555 currentNotifcationUpdateTimer = scheduler.scheduleWithFixedDelay(() -> {
556 updateNotificationTimerState();
557 }, 1, 1, TimeUnit.SECONDS);
563 if (channelId.equals(CHANNEL_TEXT_TO_SPEECH)) {
564 if (command instanceof StringType) {
565 String text = command.toFullString();
566 if (!text.isEmpty()) {
567 waitForUpdate = 1000;
568 updateTextToSpeech = true;
569 startTextToSpeech(connection, device, text);
573 if (channelId.equals(CHANNEL_TEXT_TO_SPEECH_VOLUME)) {
574 if (command instanceof PercentType) {
575 PercentType value = (PercentType) command;
576 textToSpeechVolume = value.intValue();
577 } else if (command == OnOffType.OFF) {
578 textToSpeechVolume = 0;
579 } else if (command == OnOffType.ON) {
580 textToSpeechVolume = lastKnownVolume;
581 } else if (command == IncreaseDecreaseType.INCREASE) {
582 if (textToSpeechVolume < 100) {
583 textToSpeechVolume++;
585 } else if (command == IncreaseDecreaseType.DECREASE) {
586 if (textToSpeechVolume > 0) {
587 textToSpeechVolume--;
590 this.updateState(channelId, new PercentType(textToSpeechVolume));
592 if (channelId.equals(CHANNEL_LAST_VOICE_COMMAND)) {
593 if (command instanceof StringType) {
594 String text = command.toFullString();
595 if (!text.isEmpty()) {
597 startTextToSpeech(connection, device, text);
601 if (channelId.equals(CHANNEL_START_COMMAND)) {
602 if (command instanceof StringType) {
603 String commandText = command.toFullString();
604 if (!commandText.isEmpty()) {
605 updateStartCommand = true;
606 if (commandText.startsWith(FLASH_BRIEFING_COMMAND_PREFIX)) {
607 // Handle custom flashbriefings commands
608 String flashBriefingId = commandText.substring(FLASH_BRIEFING_COMMAND_PREFIX.length());
609 for (FlashBriefingProfileHandler flashBriefingHandler : account
610 .getFlashBriefingProfileHandlers()) {
611 ThingUID flashBriefingUid = flashBriefingHandler.getThing().getUID();
612 if (flashBriefingId.equals(flashBriefingHandler.getThing().getUID().getId())) {
613 flashBriefingHandler.handleCommand(
614 new ChannelUID(flashBriefingUid, CHANNEL_PLAY_ON_DEVICE),
615 new StringType(device.serialNumber));
620 // Handle standard commands
621 if (!commandText.startsWith("Alexa.")) {
622 commandText = "Alexa." + commandText + ".Play";
624 waitForUpdate = 1000;
625 connection.executeSequenceCommand(device, commandText, Map.of());
630 if (channelId.equals(CHANNEL_START_ROUTINE)) {
631 if (command instanceof StringType) {
632 String utterance = command.toFullString();
633 if (!utterance.isEmpty()) {
634 waitForUpdate = 1000;
635 updateRoutine = true;
636 connection.startRoutine(device, utterance);
640 if (waitForUpdate < 0) {
643 // force update of the state
644 this.disableUpdate = true;
645 final boolean bluetoothRefresh = needBluetoothRefresh;
646 Runnable doRefresh = () -> {
647 this.disableUpdate = false;
648 BluetoothState state = null;
649 if (bluetoothRefresh) {
650 JsonBluetoothStates states;
651 states = connection.getBluetoothConnectionStates();
652 if (states != null) {
653 state = states.findStateByDevice(device);
657 updateState(account, device, state, null, null, null, null, null);
659 if (command instanceof RefreshType) {
661 account.forceCheckData();
663 if (waitForUpdate == 0) {
666 this.updateStateJob = scheduler.schedule(doRefresh, waitForUpdate, TimeUnit.MILLISECONDS);
668 } catch (IOException | URISyntaxException | InterruptedException e) {
669 logger.info("handleCommand fails", e);
673 private boolean handleEqualizerCommands(String channelId, Command command, Connection connection, Device device)
674 throws URISyntaxException {
675 if (command instanceof RefreshType) {
676 this.lastKnownEqualizer = null;
678 if (command instanceof DecimalType) {
679 DecimalType value = (DecimalType) command;
680 if (this.lastKnownEqualizer == null) {
681 updateEqualizerState();
683 JsonEqualizer lastKnownEqualizer = this.lastKnownEqualizer;
684 if (lastKnownEqualizer != null) {
685 JsonEqualizer newEqualizerSetting = lastKnownEqualizer.createClone();
686 if (channelId.equals(CHANNEL_EQUALIZER_BASS)) {
687 newEqualizerSetting.bass = value.intValue();
689 if (channelId.equals(CHANNEL_EQUALIZER_MIDRANGE)) {
690 newEqualizerSetting.mid = value.intValue();
692 if (channelId.equals(CHANNEL_EQUALIZER_TREBLE)) {
693 newEqualizerSetting.treble = value.intValue();
696 connection.setEqualizer(device, newEqualizerSetting);
698 } catch (HttpException | IOException | ConnectionException | InterruptedException e) {
699 logger.debug("Update equalizer failed", e);
700 this.lastKnownEqualizer = null;
707 private void startTextToSpeech(Connection connection, Device device, String text)
708 throws IOException, URISyntaxException {
709 Integer volume = null;
710 if (textToSpeechVolume != 0) {
711 volume = textToSpeechVolume;
713 connection.textToSpeech(device, text, volume, lastKnownVolume);
717 public void startAnnouncement(Device device, String speak, String bodyText, @Nullable String title,
718 @Nullable Integer volume) throws IOException, URISyntaxException {
719 Connection connection = this.findConnection();
720 if (connection == null) {
723 if (volume == null && textToSpeechVolume != 0) {
724 volume = textToSpeechVolume;
726 if (volume != null && volume < 0) {
727 volume = null; // the meaning of negative values is 'do not use'. The api requires null in this case.
729 connection.announcement(device, speak, bodyText, title, volume, lastKnownVolume);
732 private void stopCurrentNotification() {
733 ScheduledFuture<?> currentNotifcationUpdateTimer = this.currentNotifcationUpdateTimer;
734 if (currentNotifcationUpdateTimer != null) {
735 this.currentNotifcationUpdateTimer = null;
736 currentNotifcationUpdateTimer.cancel(true);
738 JsonNotificationResponse currentNotification = this.currentNotification;
739 if (currentNotification != null) {
740 this.currentNotification = null;
741 Connection currentConnection = this.findConnection();
742 if (currentConnection != null) {
744 currentConnection.stopNotification(currentNotification);
745 } catch (IOException | URISyntaxException | InterruptedException e) {
746 logger.warn("Stop notification failed", e);
752 private void updateNotificationTimerState() {
753 boolean stopCurrentNotification = true;
754 JsonNotificationResponse currentNotification = this.currentNotification;
756 if (currentNotification != null) {
757 Connection currentConnection = this.findConnection();
758 if (currentConnection != null) {
759 JsonNotificationResponse newState = currentConnection.getNotificationState(currentNotification);
760 if (newState != null && "ON".equals(newState.status)) {
761 stopCurrentNotification = false;
765 } catch (IOException | URISyntaxException | InterruptedException e) {
766 logger.warn("update notification state fails", e);
768 if (stopCurrentNotification) {
769 if (currentNotification != null) {
770 String type = currentNotification.type;
772 if (type.equals("Reminder")) {
773 updateState(CHANNEL_REMIND, new StringType(""));
774 updateRemind = false;
776 if (type.equals("Alarm")) {
777 updateState(CHANNEL_PLAY_ALARM_SOUND, new StringType(""));
782 stopCurrentNotification();
786 public void updateState(AccountHandler accountHandler, @Nullable Device device,
787 @Nullable BluetoothState bluetoothState, @Nullable DeviceNotificationState deviceNotificationState,
788 @Nullable AscendingAlarmModel ascendingAlarmModel, @Nullable JsonPlaylists playlists,
789 @Nullable JsonNotificationSound @Nullable [] alarmSounds,
790 @Nullable List<JsonMusicProvider> musicProviders) {
792 this.logger.debug("Handle updateState {}", this.getThing().getUID());
794 if (deviceNotificationState != null) {
795 notificationVolumeLevel = deviceNotificationState.volumeLevel;
797 if (ascendingAlarmModel != null) {
798 ascendingAlarm = ascendingAlarmModel.ascendingAlarmEnabled;
800 if (playlists != null) {
801 this.playLists = playlists;
803 if (alarmSounds != null) {
804 this.alarmSounds = alarmSounds;
806 if (musicProviders != null) {
807 this.musicProviders = musicProviders;
809 if (!setDeviceAndUpdateThingState(accountHandler, device, null)) {
810 this.logger.debug("Handle updateState {} aborted: Not online", this.getThing().getUID());
813 if (device == null) {
814 this.logger.debug("Handle updateState {} aborted: No device", this.getThing().getUID());
818 if (this.disableUpdate) {
819 this.logger.debug("Handle updateState {} aborted: Disabled", this.getThing().getUID());
822 Connection connection = this.findConnection();
823 if (connection == null) {
827 if (this.lastKnownEqualizer == null) {
828 updateEqualizerState();
831 PlayerInfo playerInfo = null;
832 Provider provider = null;
833 InfoText infoText = null;
834 MainArt mainArt = null;
835 String musicProviderId = null;
836 Progress progress = null;
838 JsonPlayerState playerState = connection.getPlayer(device);
839 if (playerState != null) {
840 playerInfo = playerState.playerInfo;
841 if (playerInfo != null) {
842 infoText = playerInfo.infoText;
843 if (infoText == null) {
844 infoText = playerInfo.miniInfoText;
846 mainArt = playerInfo.mainArt;
847 provider = playerInfo.provider;
848 if (provider != null) {
849 musicProviderId = provider.providerName;
850 // Map the music provider id to the one used for starting music with voice command
851 if (musicProviderId != null) {
852 musicProviderId = musicProviderId.toUpperCase();
854 if (musicProviderId.equals("AMAZON MUSIC")) {
855 musicProviderId = "AMAZON_MUSIC";
857 if (musicProviderId.equals("CLOUD_PLAYER")) {
858 musicProviderId = "AMAZON_MUSIC";
860 if (musicProviderId.startsWith("TUNEIN")) {
861 musicProviderId = "TUNEIN";
863 if (musicProviderId.startsWith("IHEARTRADIO")) {
864 musicProviderId = "I_HEART_RADIO";
866 if (musicProviderId.equals("APPLE") && musicProviderId.contains("MUSIC")) {
867 musicProviderId = "APPLE_MUSIC";
871 progress = playerInfo.progress;
874 } catch (HttpException e) {
875 if (e.getCode() != 400) {
876 logger.info("getPlayer fails", e);
878 } catch (IOException | URISyntaxException | InterruptedException e) {
879 logger.info("getPlayer fails", e);
882 isPlaying = (playerInfo != null && "PLAYING".equals(playerInfo.state));
884 isPaused = (playerInfo != null && "PAUSED".equals(playerInfo.state));
885 synchronized (progressLock) {
886 Boolean showTime = null;
887 Long mediaLength = null;
888 Long mediaProgress = null;
889 if (progress != null) {
890 showTime = progress.showTiming;
891 mediaLength = progress.mediaLength;
892 mediaProgress = progress.mediaProgress;
894 if (showTime != null && showTime && mediaProgress != null && mediaLength != null) {
895 mediaProgressMs = mediaProgress * 1000;
896 mediaLengthMs = mediaLength * 1000;
897 mediaStartMs = System.currentTimeMillis() - mediaProgressMs;
899 if (updateProgressJob == null) {
900 updateProgressJob = scheduler.scheduleWithFixedDelay(this::updateMediaProgress, 1000, 1000,
901 TimeUnit.MILLISECONDS);
912 updateMediaProgress(true);
915 JsonMediaState mediaState = null;
917 if ("AMAZON_MUSIC".equalsIgnoreCase(musicProviderId) || "TUNEIN".equalsIgnoreCase(musicProviderId)) {
918 mediaState = connection.getMediaState(device);
920 } catch (HttpException e) {
921 if (e.getCode() == 400) {
922 updateState(CHANNEL_RADIO_STATION_ID, new StringType(""));
924 logger.info("getMediaState fails", e);
926 } catch (IOException | URISyntaxException | InterruptedException e) {
927 logger.info("getMediaState fails", e);
930 // handle music provider id
931 if (provider != null && isPlaying) {
932 if (musicProviderId != null) {
933 this.musicProviderId = musicProviderId;
937 // handle amazon music
938 String amazonMusicTrackId = "";
939 String amazonMusicPlayListId = "";
940 boolean amazonMusic = false;
941 if (mediaState != null) {
942 String contentId = mediaState.contentId;
943 if (isPlaying && "CLOUD_PLAYER".equals(mediaState.providerId) && contentId != null
944 && !contentId.isEmpty()) {
945 amazonMusicTrackId = contentId;
946 lastKnownAmazonMusicId = amazonMusicTrackId;
952 String bluetoothMAC = "";
953 String bluetoothDeviceName = "";
954 boolean bluetoothIsConnected = false;
955 if (bluetoothState != null) {
956 this.bluetoothState = bluetoothState;
957 PairedDevice[] pairedDeviceList = bluetoothState.pairedDeviceList;
958 if (pairedDeviceList != null) {
959 for (PairedDevice paired : pairedDeviceList) {
960 if (paired == null) {
963 String pairedAddress = paired.address;
964 if (paired.connected && pairedAddress != null) {
965 bluetoothIsConnected = true;
966 bluetoothMAC = pairedAddress;
967 bluetoothDeviceName = paired.friendlyName;
968 if (bluetoothDeviceName == null || bluetoothDeviceName.isEmpty()) {
969 bluetoothDeviceName = pairedAddress;
976 if (!bluetoothMAC.isEmpty()) {
977 lastKnownBluetoothMAC = bluetoothMAC;
981 boolean isRadio = false;
982 String radioStationId = "";
983 if (mediaState != null) {
984 radioStationId = Objects.requireNonNullElse(mediaState.radioStationId, "");
985 if (!radioStationId.isEmpty()) {
986 lastKnownRadioStationId = radioStationId;
987 if ("TUNEIN".equalsIgnoreCase(musicProviderId)) {
989 if (!"PLAYING".equals(mediaState.currentState)) {
996 // handle title, subtitle, imageUrl
998 String subTitle1 = "";
999 String subTitle2 = "";
1000 String imageUrl = "";
1001 if (infoText != null) {
1002 if (infoText.title != null) {
1003 title = infoText.title;
1005 if (infoText.subText1 != null) {
1006 subTitle1 = infoText.subText1;
1009 if (infoText.subText2 != null) {
1010 subTitle2 = infoText.subText2;
1013 if (mainArt != null) {
1014 if (mainArt.url != null) {
1015 imageUrl = mainArt.url;
1018 if (mediaState != null) {
1019 QueueEntry[] queueEntries = mediaState.queue;
1020 if (queueEntries != null && queueEntries.length > 0) {
1021 QueueEntry entry = queueEntries[0];
1022 if (entry != null) {
1024 if ((imageUrl == null || imageUrl.isEmpty()) && entry.imageURL != null) {
1025 imageUrl = entry.imageURL;
1027 if ((subTitle1 == null || subTitle1.isEmpty()) && entry.radioStationSlogan != null) {
1028 subTitle1 = entry.radioStationSlogan;
1030 if ((subTitle2 == null || subTitle2.isEmpty()) && entry.radioStationLocation != null) {
1031 subTitle2 = entry.radioStationLocation;
1039 String providerDisplayName = "";
1040 if (provider != null) {
1041 if (provider.providerDisplayName != null) {
1042 providerDisplayName = Objects.requireNonNullElse(provider.providerDisplayName, providerDisplayName);
1044 String providerName = provider.providerName;
1045 if (providerName != null && !providerName.isEmpty() && providerDisplayName.isEmpty()) {
1046 providerDisplayName = provider.providerName;
1051 Integer volume = null;
1052 if (!connection.isSequenceNodeQueueRunning()) {
1053 if (mediaState != null) {
1054 volume = mediaState.volume;
1056 if (playerInfo != null && volume == null) {
1057 Volume volumnInfo = playerInfo.volume;
1058 if (volumnInfo != null) {
1059 volume = volumnInfo.volume;
1062 if (volume != null && volume > 0) {
1063 lastKnownVolume = volume;
1065 if (volume == null) {
1066 volume = lastKnownVolume;
1070 if (updateRemind && currentNotifcationUpdateTimer == null) {
1071 updateRemind = false;
1072 updateState(CHANNEL_REMIND, new StringType(""));
1074 if (updateAlarm && currentNotifcationUpdateTimer == null) {
1075 updateAlarm = false;
1076 updateState(CHANNEL_PLAY_ALARM_SOUND, new StringType(""));
1078 if (updateRoutine) {
1079 updateRoutine = false;
1080 updateState(CHANNEL_START_ROUTINE, new StringType(""));
1082 if (updateTextToSpeech) {
1083 updateTextToSpeech = false;
1084 updateState(CHANNEL_TEXT_TO_SPEECH, new StringType(""));
1086 if (updatePlayMusicVoiceCommand) {
1087 updatePlayMusicVoiceCommand = false;
1088 updateState(CHANNEL_PLAY_MUSIC_VOICE_COMMAND, new StringType(""));
1090 if (updateStartCommand) {
1091 updateStartCommand = false;
1092 updateState(CHANNEL_START_COMMAND, new StringType(""));
1095 updateState(CHANNEL_MUSIC_PROVIDER_ID, new StringType(musicProviderId));
1096 updateState(CHANNEL_AMAZON_MUSIC_TRACK_ID, new StringType(amazonMusicTrackId));
1097 updateState(CHANNEL_AMAZON_MUSIC, isPlaying && amazonMusic ? OnOffType.ON : OnOffType.OFF);
1098 updateState(CHANNEL_AMAZON_MUSIC_PLAY_LIST_ID, new StringType(amazonMusicPlayListId));
1099 updateState(CHANNEL_RADIO_STATION_ID, new StringType(radioStationId));
1100 updateState(CHANNEL_RADIO, isPlaying && isRadio ? OnOffType.ON : OnOffType.OFF);
1101 updateState(CHANNEL_PROVIDER_DISPLAY_NAME, new StringType(providerDisplayName));
1102 updateState(CHANNEL_PLAYER, isPlaying ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
1103 updateState(CHANNEL_IMAGE_URL, new StringType(imageUrl));
1104 updateState(CHANNEL_TITLE, new StringType(title));
1105 if (volume != null) {
1106 updateState(CHANNEL_VOLUME, new PercentType(volume));
1108 updateState(CHANNEL_SUBTITLE1, new StringType(subTitle1));
1109 updateState(CHANNEL_SUBTITLE2, new StringType(subTitle2));
1110 if (bluetoothState != null) {
1111 updateState(CHANNEL_BLUETOOTH, bluetoothIsConnected ? OnOffType.ON : OnOffType.OFF);
1112 updateState(CHANNEL_BLUETOOTH_MAC, new StringType(bluetoothMAC));
1113 updateState(CHANNEL_BLUETOOTH_DEVICE_NAME, new StringType(bluetoothDeviceName));
1116 updateState(CHANNEL_ASCENDING_ALARM,
1117 ascendingAlarm != null ? (ascendingAlarm ? OnOffType.ON : OnOffType.OFF) : UnDefType.UNDEF);
1119 final Integer notificationVolumeLevel = this.notificationVolumeLevel;
1120 if (notificationVolumeLevel != null) {
1121 updateState(CHANNEL_NOTIFICATION_VOLUME, new PercentType(notificationVolumeLevel));
1123 updateState(CHANNEL_NOTIFICATION_VOLUME, UnDefType.UNDEF);
1125 } catch (Exception e) {
1126 this.logger.debug("Handle updateState {} failed: {}", this.getThing().getUID(), e.getMessage(), e);
1128 disableUpdate = false;
1129 throw e; // Rethrow same exception
1133 private void updateEqualizerState() {
1134 if (!this.capabilities.contains("SOUND_SETTINGS")) {
1138 Connection connection = findConnection();
1139 if (connection == null) {
1142 Device device = findDevice();
1143 if (device == null) {
1146 Integer bass = null;
1147 Integer midrange = null;
1148 Integer treble = null;
1150 JsonEqualizer equalizer = connection.getEqualizer(device);
1151 if (equalizer != null) {
1152 bass = equalizer.bass;
1153 midrange = equalizer.mid;
1154 treble = equalizer.treble;
1156 this.lastKnownEqualizer = equalizer;
1157 } catch (IOException | URISyntaxException | HttpException | ConnectionException | InterruptedException e) {
1158 logger.debug("Get equalizer failes", e);
1162 updateState(CHANNEL_EQUALIZER_BASS, new DecimalType(bass));
1164 if (midrange != null) {
1165 updateState(CHANNEL_EQUALIZER_MIDRANGE, new DecimalType(midrange));
1167 if (treble != null) {
1168 updateState(CHANNEL_EQUALIZER_TREBLE, new DecimalType(treble));
1172 private void updateMediaProgress() {
1173 updateMediaProgress(false);
1176 private void updateMediaProgress(boolean updateMediaLength) {
1177 synchronized (progressLock) {
1178 if (mediaStartMs > 0) {
1179 long currentPlayTimeMs = isPlaying ? System.currentTimeMillis() - mediaStartMs : mediaProgressMs;
1180 if (mediaLengthMs > 0) {
1181 int progressPercent = (int) Math.min(100,
1182 Math.round((double) currentPlayTimeMs / (double) mediaLengthMs * 100));
1183 updateState(CHANNEL_MEDIA_PROGRESS, new PercentType(progressPercent));
1185 updateState(CHANNEL_MEDIA_PROGRESS, UnDefType.UNDEF);
1187 updateState(CHANNEL_MEDIA_PROGRESS_TIME, new QuantityType<>(currentPlayTimeMs / 1000, Units.SECOND));
1188 if (updateMediaLength) {
1189 updateState(CHANNEL_MEDIA_LENGTH, new QuantityType<>(mediaLengthMs / 1000, Units.SECOND));
1192 updateState(CHANNEL_MEDIA_PROGRESS, UnDefType.UNDEF);
1193 updateState(CHANNEL_MEDIA_LENGTH, UnDefType.UNDEF);
1194 updateState(CHANNEL_MEDIA_PROGRESS_TIME, UnDefType.UNDEF);
1195 if (updateMediaLength) {
1196 updateState(CHANNEL_MEDIA_LENGTH, UnDefType.UNDEF);
1202 public void handlePushActivity(Activity pushActivity) {
1203 if ("DISCARDED_NON_DEVICE_DIRECTED_INTENT".equals(pushActivity.activityStatus)) {
1206 Description description = pushActivity.parseDescription();
1207 String firstUtteranceId = description.firstUtteranceId;
1208 if (firstUtteranceId == null || firstUtteranceId.isEmpty()
1209 || firstUtteranceId.toLowerCase().startsWith("textclient:")) {
1212 String firstStreamId = description.firstStreamId;
1213 if (firstStreamId == null || firstStreamId.isEmpty()) {
1216 String spokenText = description.summary;
1217 if (spokenText != null && !spokenText.isEmpty()) {
1219 String wakeWordPrefix = this.wakeWord;
1220 if (wakeWordPrefix != null) {
1221 wakeWordPrefix += " ";
1222 if (spokenText.toLowerCase().startsWith(wakeWordPrefix.toLowerCase())) {
1223 spokenText = spokenText.substring(wakeWordPrefix.length());
1227 if (lastSpokenText.isEmpty() || lastSpokenText.equals(spokenText)) {
1228 updateState(CHANNEL_LAST_VOICE_COMMAND, new StringType(""));
1230 lastSpokenText = spokenText;
1231 updateState(CHANNEL_LAST_VOICE_COMMAND, new StringType(spokenText));
1235 public void handlePushCommand(String command, String payload) {
1236 this.logger.debug("Handle push command {}", command);
1238 case "PUSH_VOLUME_CHANGE":
1239 JsonCommandPayloadPushVolumeChange volumeChange = Objects
1240 .requireNonNull(gson.fromJson(payload, JsonCommandPayloadPushVolumeChange.class));
1241 Connection connection = this.findConnection();
1242 Integer volumeSetting = volumeChange.volumeSetting;
1243 Boolean muted = volumeChange.isMuted;
1244 if (muted != null && muted) {
1245 updateState(CHANNEL_VOLUME, new PercentType(0));
1247 if (volumeSetting != null && connection != null && !connection.isSequenceNodeQueueRunning()) {
1248 lastKnownVolume = volumeSetting;
1249 updateState(CHANNEL_VOLUME, new PercentType(lastKnownVolume));
1252 case "PUSH_EQUALIZER_STATE_CHANGE":
1253 updateEqualizerState();
1256 AccountHandler account = this.account;
1257 Device device = this.device;
1258 if (account != null && device != null) {
1259 this.disableUpdate = false;
1260 updateState(account, device, null, null, null, null, null, null);
1265 public void updateNotifications(ZonedDateTime currentTime, ZonedDateTime now,
1266 @Nullable JsonCommandPayloadPushNotificationChange pushPayload, JsonNotificationResponse[] notifications) {
1267 Device device = this.device;
1268 if (device == null) {
1272 ZonedDateTime nextReminder = null;
1273 ZonedDateTime nextAlarm = null;
1274 ZonedDateTime nextMusicAlarm = null;
1275 ZonedDateTime nextTimer = null;
1276 for (JsonNotificationResponse notification : notifications) {
1277 if (Objects.equals(notification.deviceSerialNumber, device.serialNumber)) {
1278 // notification for this device
1279 if ("ON".equals(notification.status)) {
1280 if ("Reminder".equals(notification.type)) {
1281 String offset = ZoneId.systemDefault().getRules().getOffset(Instant.now()).toString();
1282 String date = notification.originalDate != null ? notification.originalDate
1283 : ZonedDateTime.now().toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE);
1284 String time = notification.originalTime != null ? notification.originalTime : "00:00:00";
1285 ZonedDateTime alarmTime = ZonedDateTime.parse(date + "T" + time + offset,
1286 DateTimeFormatter.ISO_DATE_TIME);
1287 String recurringPattern = notification.recurringPattern;
1288 if (recurringPattern != null && !recurringPattern.isBlank() && alarmTime.isBefore(now)) {
1289 continue; // Ignore recurring entry if alarm time is before now
1291 if (nextReminder == null || alarmTime.isBefore(nextReminder)) {
1292 nextReminder = alarmTime;
1294 } else if ("Timer".equals(notification.type)) {
1295 // use remaining time
1296 ZonedDateTime alarmTime = currentTime.plus(notification.remainingTime, ChronoUnit.MILLIS);
1297 if (nextTimer == null || alarmTime.isBefore(nextTimer)) {
1298 nextTimer = alarmTime;
1300 } else if ("Alarm".equals(notification.type)) {
1301 String offset = ZoneId.systemDefault().getRules().getOffset(Instant.now()).toString();
1302 ZonedDateTime alarmTime = ZonedDateTime
1303 .parse(notification.originalDate + "T" + notification.originalTime + offset);
1304 String recurringPattern = notification.recurringPattern;
1305 if (recurringPattern != null && !recurringPattern.isBlank() && alarmTime.isBefore(now)) {
1306 continue; // Ignore recurring entry if alarm time is before now
1308 if (nextAlarm == null || alarmTime.isBefore(nextAlarm)) {
1309 nextAlarm = alarmTime;
1311 } else if ("MusicAlarm".equals(notification.type)) {
1312 String offset = ZoneId.systemDefault().getRules().getOffset(Instant.now()).toString();
1313 ZonedDateTime alarmTime = ZonedDateTime
1314 .parse(notification.originalDate + "T" + notification.originalTime + offset);
1315 String recurringPattern = notification.recurringPattern;
1316 if (recurringPattern != null && !recurringPattern.isBlank() && alarmTime.isBefore(now)) {
1317 continue; // Ignore recurring entry if alarm time is before now
1319 if (nextMusicAlarm == null || alarmTime.isBefore(nextMusicAlarm)) {
1320 nextMusicAlarm = alarmTime;
1327 updateState(CHANNEL_NEXT_REMINDER, nextReminder == null ? UnDefType.UNDEF : new DateTimeType(nextReminder));
1328 updateState(CHANNEL_NEXT_ALARM, nextAlarm == null ? UnDefType.UNDEF : new DateTimeType(nextAlarm));
1329 updateState(CHANNEL_NEXT_MUSIC_ALARM,
1330 nextMusicAlarm == null ? UnDefType.UNDEF : new DateTimeType(nextMusicAlarm));
1331 updateState(CHANNEL_NEXT_TIMER, nextTimer == null ? UnDefType.UNDEF : new DateTimeType(nextTimer));
1335 public void updateChannelState(String channelId, State state) {
1336 updateState(channelId, state);