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.androiddebugbridge.internal;
15 import static org.openhab.binding.androiddebugbridge.internal.AndroidDebugBridgeBindingConstants.*;
17 import java.util.Arrays;
18 import java.util.List;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
24 import java.util.regex.Pattern;
25 import java.util.stream.Collectors;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.core.library.types.DecimalType;
30 import org.openhab.core.library.types.IncreaseDecreaseType;
31 import org.openhab.core.library.types.NextPreviousType;
32 import org.openhab.core.library.types.OnOffType;
33 import org.openhab.core.library.types.PercentType;
34 import org.openhab.core.library.types.PlayPauseType;
35 import org.openhab.core.library.types.RewindFastforwardType;
36 import org.openhab.core.library.types.StringType;
37 import org.openhab.core.thing.ChannelUID;
38 import org.openhab.core.thing.Thing;
39 import org.openhab.core.thing.ThingStatus;
40 import org.openhab.core.thing.ThingStatusDetail;
41 import org.openhab.core.thing.binding.BaseThingHandler;
42 import org.openhab.core.types.Command;
43 import org.openhab.core.types.CommandOption;
44 import org.openhab.core.types.RefreshType;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
48 import com.google.gson.Gson;
49 import com.google.gson.JsonSyntaxException;
52 * The {@link AndroidDebugBridgeHandler} is responsible for handling commands, which are
53 * sent to one of the channels.
55 * @author Miguel Álvarez - Initial contribution
58 public class AndroidDebugBridgeHandler extends BaseThingHandler {
59 public static final String KEY_EVENT_PLAY = "126";
60 public static final String KEY_EVENT_PAUSE = "127";
61 public static final String KEY_EVENT_NEXT = "87";
62 public static final String KEY_EVENT_PREVIOUS = "88";
63 public static final String KEY_EVENT_MEDIA_REWIND = "89";
64 public static final String KEY_EVENT_MEDIA_FAST_FORWARD = "90";
65 private static final String SHUTDOWN_POWER_OFF = "POWER_OFF";
66 private static final String SHUTDOWN_REBOOT = "REBOOT";
67 private static final Gson GSON = new Gson();
68 private static final Pattern RECORD_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]*$");
69 private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeHandler.class);
71 private final AndroidDebugBridgeDynamicCommandDescriptionProvider commandDescriptionProvider;
72 private final AndroidDebugBridgeDevice adbConnection;
73 private int maxMediaVolume = 0;
74 private AndroidDebugBridgeConfiguration config = new AndroidDebugBridgeConfiguration();
75 private @Nullable ScheduledFuture<?> connectionCheckerSchedule;
76 private AndroidDebugBridgeMediaStatePackageConfig @Nullable [] packageConfigs = null;
77 private boolean deviceAwake = false;
78 private int consecutiveTimeouts = 0;
80 public AndroidDebugBridgeHandler(Thing thing,
81 AndroidDebugBridgeDynamicCommandDescriptionProvider commandDescriptionProvider) {
83 this.commandDescriptionProvider = commandDescriptionProvider;
84 this.adbConnection = new AndroidDebugBridgeDevice(scheduler);
88 public void handleCommand(ChannelUID channelUID, Command command) {
89 AndroidDebugBridgeConfiguration currentConfig = config;
91 if (!adbConnection.isConnected()) {
93 adbConnection.connect();
95 handleCommandInternal(channelUID, command);
96 } catch (InterruptedException ignored) {
97 } catch (AndroidDebugBridgeDeviceException | ExecutionException e) {
98 if (!(e.getCause() instanceof InterruptedException)) {
99 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
100 adbConnection.disconnect();
102 } catch (AndroidDebugBridgeDeviceReadException e) {
103 logger.warn("{} - read error: {}", currentConfig.ip, e.getMessage());
104 } catch (TimeoutException e) {
105 logger.warn("{} - timeout error", currentConfig.ip);
106 disconnectOnMaxADBTimeouts();
110 private void handleCommandInternal(ChannelUID channelUID, Command command)
111 throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
112 TimeoutException, ExecutionException {
113 if (!isLinked(channelUID)) {
116 String channelId = channelUID.getId();
118 case KEY_EVENT_CHANNEL:
119 adbConnection.sendKeyEvent(command.toFullString());
122 adbConnection.sendText(command.toFullString());
125 adbConnection.sendTap(command.toFullString());
128 adbConnection.openUrl(command.toFullString());
130 case MEDIA_VOLUME_CHANNEL:
131 handleMediaVolume(channelUID, command);
133 case MEDIA_CONTROL_CHANNEL:
134 handleMediaControlCommand(channelUID, command);
136 case START_PACKAGE_CHANNEL:
137 adbConnection.startPackage(command.toFullString());
138 updateState(new ChannelUID(this.thing.getUID(), CURRENT_PACKAGE_CHANNEL),
139 new StringType(command.toFullString()));
141 case STOP_PACKAGE_CHANNEL:
142 adbConnection.stopPackage(command.toFullString());
144 case STOP_CURRENT_PACKAGE_CHANNEL:
145 if (OnOffType.from(command.toFullString()).equals(OnOffType.OFF)) {
146 adbConnection.stopPackage(adbConnection.getCurrentPackage());
149 case CURRENT_PACKAGE_CHANNEL:
150 if (command instanceof RefreshType) {
151 var packageName = adbConnection.getCurrentPackage();
152 updateState(channelUID, new StringType(packageName));
155 case WAKE_LOCK_CHANNEL:
156 if (command instanceof RefreshType) {
157 int lock = adbConnection.getPowerWakeLock();
158 updateState(channelUID, new DecimalType(lock));
161 case AWAKE_STATE_CHANNEL:
162 if (command instanceof RefreshType) {
163 boolean awakeState = adbConnection.isAwake();
164 updateState(channelUID, OnOffType.from(awakeState));
167 case SCREEN_STATE_CHANNEL:
168 if (command instanceof RefreshType) {
169 boolean screenState = adbConnection.isScreenOn();
170 updateState(channelUID, OnOffType.from(screenState));
173 case SHUTDOWN_CHANNEL:
174 switch (command.toFullString()) {
175 case SHUTDOWN_POWER_OFF:
176 adbConnection.powerOffDevice();
177 updateStatus(ThingStatus.OFFLINE);
179 case SHUTDOWN_REBOOT:
180 adbConnection.rebootDevice();
181 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Rebooting");
185 case START_INTENT_CHANNEL:
186 if (command instanceof RefreshType) {
189 adbConnection.startIntent(command.toFullString());
191 case RECORD_INPUT_CHANNEL:
192 recordDeviceInput(command);
194 case RECORDED_INPUT_CHANNEL:
195 String recordName = getRecordPropertyName(command);
196 var inputCommand = this.getThing().getProperties().get(recordName);
197 if (inputCommand != null) {
198 adbConnection.sendInputEvents(inputCommand);
202 consecutiveTimeouts = 0;
205 private void recordDeviceInput(Command recordNameCommand)
206 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
207 var recordName = recordNameCommand.toFullString();
208 if (!RECORD_NAME_PATTERN.matcher(recordName).matches()) {
209 logger.warn("Invalid record name, accepts alphanumeric values with '_'.");
212 String recordPropertyName = getRecordPropertyName(recordName);
213 logger.debug("RECORD: {}", recordPropertyName);
214 var eventCommand = adbConnection.recordInputEvents();
215 if (eventCommand.isEmpty()) {
216 logger.debug("No events recorded");
217 if (this.getThing().getProperties().containsKey(recordPropertyName)) {
218 this.getThing().setProperty(recordPropertyName, null);
219 updateProperties(editProperties());
220 logger.debug("Record {} deleted", recordName);
223 updateProperty(recordPropertyName, eventCommand);
224 logger.debug("New record {}: {}", recordName, eventCommand);
228 private String getRecordPropertyName(String recordName) {
229 return String.format("input-record:%s", recordName);
232 private String getRecordPropertyName(Command recordNameCommand) {
233 return getRecordPropertyName(recordNameCommand.toFullString());
236 private void handleMediaVolume(ChannelUID channelUID, Command command)
237 throws InterruptedException, AndroidDebugBridgeDeviceReadException, AndroidDebugBridgeDeviceException,
238 TimeoutException, ExecutionException {
239 if (command instanceof RefreshType) {
240 var volumeInfo = adbConnection.getMediaVolume();
241 maxMediaVolume = volumeInfo.max;
242 updateState(channelUID, new PercentType((int) Math.round(toPercent(volumeInfo.current, volumeInfo.max))));
243 } else if (command instanceof IncreaseDecreaseType) {
244 var volumeInfo = adbConnection.getMediaVolume();
245 var volumeStep = fromPercent(config.volumeStepPercent, volumeInfo.max);
246 logger.debug("Device {} volume step: {}", getThing().getUID(), volumeStep);
247 var targetVolume = (int) Math
248 .round(IncreaseDecreaseType.INCREASE.equals(command) ? volumeInfo.current + volumeStep
249 : volumeInfo.current - volumeStep);
250 var newVolume = Integer.max(0, Integer.min(targetVolume, volumeInfo.max));
251 logger.debug("Device {} new volume : {}", getThing().getUID(), newVolume);
252 adbConnection.setMediaVolume(newVolume);
254 if (maxMediaVolume == 0) {
255 return; // We can not transform percentage
257 int targetVolume = Integer.parseInt(command.toFullString());
258 adbConnection.setMediaVolume((int) Math.round(fromPercent(targetVolume, maxMediaVolume)));
259 updateState(channelUID, new PercentType(targetVolume));
263 private double toPercent(double value, double maxValue) {
264 return (value / maxValue) * 100;
267 private double fromPercent(double percent, double maxValue) {
268 return (percent / 100) * maxValue;
271 private void handleMediaControlCommand(ChannelUID channelUID, Command command)
272 throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
273 TimeoutException, ExecutionException {
274 if (command instanceof RefreshType) {
276 String currentPackage = adbConnection.getCurrentPackage();
277 var currentPackageConfig = packageConfigs != null ? Arrays.stream(packageConfigs)
278 .filter(pc -> pc.name.equals(currentPackage)).findFirst().orElse(null) : null;
279 if (currentPackageConfig != null) {
280 logger.debug("media stream config found for {}, mode: {}", currentPackage, currentPackageConfig.mode);
281 switch (currentPackageConfig.mode) {
286 int wakeLockState = adbConnection.getPowerWakeLock();
287 playing = currentPackageConfig.wakeLockPlayStates.contains(wakeLockState);
290 playing = adbConnection.isPlayingMedia(currentPackage);
293 playing = adbConnection.isPlayingAudio();
296 logger.warn("media state config: package {} unsupported mode", currentPackage);
300 logger.debug("media stream config not found for {}", currentPackage);
301 playing = adbConnection.isPlayingMedia(currentPackage);
303 updateState(channelUID, playing ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
304 } else if (command instanceof PlayPauseType) {
305 if (command == PlayPauseType.PLAY) {
306 adbConnection.sendKeyEvent(KEY_EVENT_PLAY);
307 updateState(channelUID, PlayPauseType.PLAY);
308 } else if (command == PlayPauseType.PAUSE) {
309 adbConnection.sendKeyEvent(KEY_EVENT_PAUSE);
310 updateState(channelUID, PlayPauseType.PAUSE);
312 } else if (command instanceof NextPreviousType) {
313 if (command == NextPreviousType.NEXT) {
314 adbConnection.sendKeyEvent(KEY_EVENT_NEXT);
315 } else if (command == NextPreviousType.PREVIOUS) {
316 adbConnection.sendKeyEvent(KEY_EVENT_PREVIOUS);
318 } else if (command instanceof RewindFastforwardType) {
319 if (command == RewindFastforwardType.FASTFORWARD) {
320 adbConnection.sendKeyEvent(KEY_EVENT_MEDIA_FAST_FORWARD);
321 } else if (command == RewindFastforwardType.REWIND) {
322 adbConnection.sendKeyEvent(KEY_EVENT_MEDIA_REWIND);
325 logger.warn("Unknown media control command: {}", command);
330 public void initialize() {
331 AndroidDebugBridgeConfiguration currentConfig = getConfigAs(AndroidDebugBridgeConfiguration.class);
332 config = currentConfig;
333 var mediaStateJSONConfig = currentConfig.mediaStateJSONConfig;
334 if (mediaStateJSONConfig != null && !mediaStateJSONConfig.isEmpty()) {
335 loadMediaStateConfig(mediaStateJSONConfig);
337 adbConnection.configure(currentConfig);
338 var androidVersion = thing.getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION);
339 if (androidVersion != null) {
340 // configure android implementation to use
341 adbConnection.setAndroidVersion(androidVersion);
343 updateStatus(ThingStatus.UNKNOWN);
344 connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(this::checkConnection, 0,
345 currentConfig.refreshTime, TimeUnit.SECONDS);
348 private void loadMediaStateConfig(String mediaStateJSONConfig) {
349 List<CommandOption> commandOptions;
351 packageConfigs = GSON.fromJson(mediaStateJSONConfig, AndroidDebugBridgeMediaStatePackageConfig[].class);
352 commandOptions = Arrays.stream(packageConfigs)
353 .map(AndroidDebugBridgeMediaStatePackageConfig::toCommandOption)
354 .collect(Collectors.toUnmodifiableList());
355 } catch (JsonSyntaxException e) {
356 logger.warn("unable to parse media state config: {}", e.getMessage());
357 commandOptions = List.of();
359 commandDescriptionProvider.setCommandOptions(new ChannelUID(getThing().getUID(), START_PACKAGE_CHANNEL),
364 public void dispose() {
365 var schedule = connectionCheckerSchedule;
366 if (schedule != null) {
367 schedule.cancel(true);
368 connectionCheckerSchedule = null;
370 packageConfigs = null;
371 adbConnection.disconnect();
375 public void checkConnection() {
376 AndroidDebugBridgeConfiguration currentConfig = config;
378 logger.debug("Refresh device {} status", currentConfig.ip);
379 if (adbConnection.isConnected()) {
380 if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
381 // refresh properties only on state changes
384 updateStatus(ThingStatus.ONLINE);
388 adbConnection.connect();
389 } catch (AndroidDebugBridgeDeviceException e) {
390 logger.debug("Error connecting to device; [{}]: {}", e.getClass().getCanonicalName(),
392 adbConnection.disconnect();
393 updateStatus(ThingStatus.OFFLINE);
396 if (adbConnection.isConnected()) {
397 updateStatus(ThingStatus.ONLINE);
402 } catch (InterruptedException ignored) {
403 } catch (AndroidDebugBridgeDeviceException | AndroidDebugBridgeDeviceReadException | ExecutionException e) {
404 logger.debug("Connection checker error: {}", e.getMessage());
405 adbConnection.disconnect();
406 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
410 private void refreshProperties() throws InterruptedException, AndroidDebugBridgeDeviceException,
411 AndroidDebugBridgeDeviceReadException, ExecutionException {
412 // Add some information about the device
414 Map<String, String> editProperties = editProperties();
416 editProperties.put(Thing.PROPERTY_SERIAL_NUMBER, adbConnection.getSerialNo());
417 } catch (AndroidDebugBridgeDeviceReadException ignored) {
418 // Allow devices without serial number.
421 editProperties.put(Thing.PROPERTY_MODEL_ID, adbConnection.getModel());
422 } catch (AndroidDebugBridgeDeviceReadException ignored) {
423 // Allow devices without model id.
425 var androidVersion = adbConnection.getAndroidVersion();
426 editProperties.put(Thing.PROPERTY_FIRMWARE_VERSION, androidVersion);
427 // refresh android version to use
428 adbConnection.setAndroidVersion(androidVersion);
429 editProperties.put(Thing.PROPERTY_VENDOR, adbConnection.getBrand());
431 editProperties.put(Thing.PROPERTY_MAC_ADDRESS, adbConnection.getMacAddress());
432 } catch (AndroidDebugBridgeDeviceReadException e) {
433 logger.debug("Refresh properties error: {}", e.getMessage());
435 updateProperties(editProperties);
436 } catch (TimeoutException e) {
437 logger.debug("Refresh properties error: Timeout");
442 private void refreshStatus() throws InterruptedException, AndroidDebugBridgeDeviceException, ExecutionException {
444 boolean prevDeviceAwake = deviceAwake;
446 awakeState = adbConnection.isAwake();
447 deviceAwake = awakeState;
448 } catch (TimeoutException e) {
449 // happen a lot when device is sleeping; abort refresh other channels
450 logger.debug("Unable to refresh awake state: Timeout; aborting channels refresh");
451 disconnectOnMaxADBTimeouts();
454 consecutiveTimeouts = 0;
455 var awakeStateChannelUID = new ChannelUID(this.thing.getUID(), AWAKE_STATE_CHANNEL);
456 if (isLinked(awakeStateChannelUID)) {
457 updateState(awakeStateChannelUID, OnOffType.from(awakeState));
459 if (!awakeState && !prevDeviceAwake) {
460 // abort refresh channels while device is sleeping, throws many timeouts
461 logger.debug("device {} is sleeping", config.ip);
465 handleCommandInternal(new ChannelUID(this.thing.getUID(), MEDIA_VOLUME_CHANNEL), RefreshType.REFRESH);
466 } catch (AndroidDebugBridgeDeviceReadException e) {
467 logger.warn("Unable to refresh media volume: {}", e.getMessage());
468 } catch (TimeoutException e) {
469 logger.warn("Unable to refresh media volume: Timeout");
472 handleCommandInternal(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL), RefreshType.REFRESH);
473 } catch (AndroidDebugBridgeDeviceReadException e) {
474 logger.warn("Unable to refresh play status: {}", e.getMessage());
475 } catch (TimeoutException e) {
476 logger.warn("Unable to refresh play status: Timeout");
479 handleCommandInternal(new ChannelUID(this.thing.getUID(), CURRENT_PACKAGE_CHANNEL), RefreshType.REFRESH);
480 } catch (AndroidDebugBridgeDeviceReadException e) {
481 logger.warn("Unable to refresh current package: {}", e.getMessage());
482 } catch (TimeoutException e) {
483 logger.warn("Unable to refresh current package: Timeout");
486 handleCommandInternal(new ChannelUID(this.thing.getUID(), WAKE_LOCK_CHANNEL), RefreshType.REFRESH);
487 } catch (AndroidDebugBridgeDeviceReadException e) {
488 logger.warn("Unable to refresh wake lock: {}", e.getMessage());
489 } catch (TimeoutException e) {
490 logger.warn("Unable to refresh wake lock: Timeout");
493 handleCommandInternal(new ChannelUID(this.thing.getUID(), SCREEN_STATE_CHANNEL), RefreshType.REFRESH);
494 } catch (AndroidDebugBridgeDeviceReadException e) {
495 logger.warn("Unable to refresh screen state: {}", e.getMessage());
496 } catch (TimeoutException e) {
497 logger.warn("Unable to refresh screen state: Timeout");
501 private void disconnectOnMaxADBTimeouts() {
502 consecutiveTimeouts++;
503 if (config.maxADBTimeouts > 0 && consecutiveTimeouts >= config.maxADBTimeouts) {
504 logger.debug("Max consecutive timeouts reached, aborting connection");
505 adbConnection.disconnect();
507 consecutiveTimeouts = 0;
511 static class AndroidDebugBridgeMediaStatePackageConfig {
512 public String name = "";
513 public @Nullable String label;
514 public String mode = "";
515 public List<Integer> wakeLockPlayStates = List.of();
517 public CommandOption toCommandOption() {
518 return new CommandOption(name, label == null ? name : label);