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.NextPreviousType;
31 import org.openhab.core.library.types.OnOffType;
32 import org.openhab.core.library.types.PercentType;
33 import org.openhab.core.library.types.PlayPauseType;
34 import org.openhab.core.library.types.RewindFastforwardType;
35 import org.openhab.core.library.types.StringType;
36 import org.openhab.core.thing.ChannelUID;
37 import org.openhab.core.thing.Thing;
38 import org.openhab.core.thing.ThingStatus;
39 import org.openhab.core.thing.ThingStatusDetail;
40 import org.openhab.core.thing.binding.BaseThingHandler;
41 import org.openhab.core.types.Command;
42 import org.openhab.core.types.CommandOption;
43 import org.openhab.core.types.RefreshType;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
47 import com.google.gson.Gson;
48 import com.google.gson.JsonSyntaxException;
51 * The {@link AndroidDebugBridgeHandler} is responsible for handling commands, which are
52 * sent to one of the channels.
54 * @author Miguel Álvarez - Initial contribution
57 public class AndroidDebugBridgeHandler extends BaseThingHandler {
58 public static final String KEY_EVENT_PLAY = "126";
59 public static final String KEY_EVENT_PAUSE = "127";
60 public static final String KEY_EVENT_NEXT = "87";
61 public static final String KEY_EVENT_PREVIOUS = "88";
62 public static final String KEY_EVENT_MEDIA_REWIND = "89";
63 public static final String KEY_EVENT_MEDIA_FAST_FORWARD = "90";
64 private static final String SHUTDOWN_POWER_OFF = "POWER_OFF";
65 private static final String SHUTDOWN_REBOOT = "REBOOT";
66 private static final Gson GSON = new Gson();
67 private static final Pattern RECORD_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]*$");
68 private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeHandler.class);
70 private final AndroidDebugBridgeDynamicCommandDescriptionProvider commandDescriptionProvider;
71 private final AndroidDebugBridgeDevice adbConnection;
72 private int maxMediaVolume = 0;
73 private AndroidDebugBridgeConfiguration config = new AndroidDebugBridgeConfiguration();
74 private @Nullable ScheduledFuture<?> connectionCheckerSchedule;
75 private AndroidDebugBridgeMediaStatePackageConfig @Nullable [] packageConfigs = null;
76 private boolean deviceAwake = false;
78 public AndroidDebugBridgeHandler(Thing thing,
79 AndroidDebugBridgeDynamicCommandDescriptionProvider commandDescriptionProvider) {
81 this.commandDescriptionProvider = commandDescriptionProvider;
82 this.adbConnection = new AndroidDebugBridgeDevice(scheduler);
86 public void handleCommand(ChannelUID channelUID, Command command) {
87 AndroidDebugBridgeConfiguration currentConfig = config;
89 if (!adbConnection.isConnected()) {
91 adbConnection.connect();
93 handleCommandInternal(channelUID, command);
94 } catch (InterruptedException ignored) {
95 } catch (AndroidDebugBridgeDeviceException | ExecutionException e) {
96 if (!(e.getCause() instanceof InterruptedException)) {
97 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
98 adbConnection.disconnect();
100 } catch (AndroidDebugBridgeDeviceReadException e) {
101 logger.warn("{} - read error: {}", currentConfig.ip, e.getMessage());
102 } catch (TimeoutException e) {
103 logger.warn("{} - timeout error", currentConfig.ip);
107 private void handleCommandInternal(ChannelUID channelUID, Command command)
108 throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
109 TimeoutException, ExecutionException {
110 if (!isLinked(channelUID)) {
113 String channelId = channelUID.getId();
115 case KEY_EVENT_CHANNEL:
116 adbConnection.sendKeyEvent(command.toFullString());
119 adbConnection.sendText(command.toFullString());
122 adbConnection.sendTap(command.toFullString());
125 adbConnection.openUrl(command.toFullString());
127 case MEDIA_VOLUME_CHANNEL:
128 handleMediaVolume(channelUID, command);
130 case MEDIA_CONTROL_CHANNEL:
131 handleMediaControlCommand(channelUID, command);
133 case START_PACKAGE_CHANNEL:
134 adbConnection.startPackage(command.toFullString());
135 updateState(new ChannelUID(this.thing.getUID(), CURRENT_PACKAGE_CHANNEL),
136 new StringType(command.toFullString()));
138 case STOP_PACKAGE_CHANNEL:
139 adbConnection.stopPackage(command.toFullString());
141 case STOP_CURRENT_PACKAGE_CHANNEL:
142 if (OnOffType.from(command.toFullString()).equals(OnOffType.OFF)) {
143 adbConnection.stopPackage(adbConnection.getCurrentPackage());
146 case CURRENT_PACKAGE_CHANNEL:
147 if (command instanceof RefreshType) {
148 var packageName = adbConnection.getCurrentPackage();
149 updateState(channelUID, new StringType(packageName));
152 case WAKE_LOCK_CHANNEL:
153 if (command instanceof RefreshType) {
154 int lock = adbConnection.getPowerWakeLock();
155 updateState(channelUID, new DecimalType(lock));
158 case AWAKE_STATE_CHANNEL:
159 if (command instanceof RefreshType) {
160 boolean awakeState = adbConnection.isAwake();
161 updateState(channelUID, OnOffType.from(awakeState));
164 case SCREEN_STATE_CHANNEL:
165 if (command instanceof RefreshType) {
166 boolean screenState = adbConnection.isScreenOn();
167 updateState(channelUID, OnOffType.from(screenState));
170 case SHUTDOWN_CHANNEL:
171 switch (command.toFullString()) {
172 case SHUTDOWN_POWER_OFF:
173 adbConnection.powerOffDevice();
174 updateStatus(ThingStatus.OFFLINE);
176 case SHUTDOWN_REBOOT:
177 adbConnection.rebootDevice();
178 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Rebooting");
182 case START_INTENT_CHANNEL:
183 if (command instanceof RefreshType) {
186 adbConnection.startIntent(command.toFullString());
188 case RECORD_INPUT_CHANNEL:
189 recordDeviceInput(command);
191 case RECORDED_INPUT_CHANNEL:
192 String recordName = getRecordPropertyName(command);
193 var inputCommand = this.getThing().getProperties().get(recordName);
194 if (inputCommand != null) {
195 adbConnection.sendInputEvents(inputCommand);
201 private void recordDeviceInput(Command recordNameCommand)
202 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
203 var recordName = recordNameCommand.toFullString();
204 if (!RECORD_NAME_PATTERN.matcher(recordName).matches()) {
205 logger.warn("Invalid record name, accepts alphanumeric values with '_'.");
208 String recordPropertyName = getRecordPropertyName(recordName);
209 logger.debug("RECORD: {}", recordPropertyName);
210 var eventCommand = adbConnection.recordInputEvents();
211 if (eventCommand.isEmpty()) {
212 logger.debug("No events recorded");
213 if (this.getThing().getProperties().containsKey(recordPropertyName)) {
214 this.getThing().setProperty(recordPropertyName, null);
215 updateProperties(editProperties());
216 logger.debug("Record {} deleted", recordName);
219 updateProperty(recordPropertyName, eventCommand);
220 logger.debug("New record {}: {}", recordName, eventCommand);
224 private String getRecordPropertyName(String recordName) {
225 return String.format("input-record:%s", recordName);
228 private String getRecordPropertyName(Command recordNameCommand) {
229 return getRecordPropertyName(recordNameCommand.toFullString());
232 private void handleMediaVolume(ChannelUID channelUID, Command command)
233 throws InterruptedException, AndroidDebugBridgeDeviceReadException, AndroidDebugBridgeDeviceException,
234 TimeoutException, ExecutionException {
235 if (command instanceof RefreshType) {
236 var volumeInfo = adbConnection.getMediaVolume();
237 maxMediaVolume = volumeInfo.max;
238 updateState(channelUID, new PercentType((int) Math.round(toPercent(volumeInfo.current, volumeInfo.max))));
240 if (maxMediaVolume == 0) {
241 return; // We can not transform percentage
243 int targetVolume = Integer.parseInt(command.toFullString());
244 adbConnection.setMediaVolume((int) Math.round(fromPercent(targetVolume, maxMediaVolume)));
245 updateState(channelUID, new PercentType(targetVolume));
249 private double toPercent(double value, double maxValue) {
250 return (value / maxValue) * 100;
253 private double fromPercent(double value, double maxValue) {
254 return (value / 100) * maxValue;
257 private void handleMediaControlCommand(ChannelUID channelUID, Command command)
258 throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
259 TimeoutException, ExecutionException {
260 if (command instanceof RefreshType) {
262 String currentPackage = adbConnection.getCurrentPackage();
263 var currentPackageConfig = packageConfigs != null ? Arrays.stream(packageConfigs)
264 .filter(pc -> pc.name.equals(currentPackage)).findFirst().orElse(null) : null;
265 if (currentPackageConfig != null) {
266 logger.debug("media stream config found for {}, mode: {}", currentPackage, currentPackageConfig.mode);
267 switch (currentPackageConfig.mode) {
272 int wakeLockState = adbConnection.getPowerWakeLock();
273 playing = currentPackageConfig.wakeLockPlayStates.contains(wakeLockState);
276 playing = adbConnection.isPlayingMedia(currentPackage);
279 playing = adbConnection.isPlayingAudio();
282 logger.warn("media state config: package {} unsupported mode", currentPackage);
286 logger.debug("media stream config not found for {}", currentPackage);
287 playing = adbConnection.isPlayingMedia(currentPackage);
289 updateState(channelUID, playing ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
290 } else if (command instanceof PlayPauseType) {
291 if (command == PlayPauseType.PLAY) {
292 adbConnection.sendKeyEvent(KEY_EVENT_PLAY);
293 updateState(channelUID, PlayPauseType.PLAY);
294 } else if (command == PlayPauseType.PAUSE) {
295 adbConnection.sendKeyEvent(KEY_EVENT_PAUSE);
296 updateState(channelUID, PlayPauseType.PAUSE);
298 } else if (command instanceof NextPreviousType) {
299 if (command == NextPreviousType.NEXT) {
300 adbConnection.sendKeyEvent(KEY_EVENT_NEXT);
301 } else if (command == NextPreviousType.PREVIOUS) {
302 adbConnection.sendKeyEvent(KEY_EVENT_PREVIOUS);
304 } else if (command instanceof RewindFastforwardType) {
305 if (command == RewindFastforwardType.FASTFORWARD) {
306 adbConnection.sendKeyEvent(KEY_EVENT_MEDIA_FAST_FORWARD);
307 } else if (command == RewindFastforwardType.REWIND) {
308 adbConnection.sendKeyEvent(KEY_EVENT_MEDIA_REWIND);
311 logger.warn("Unknown media control command: {}", command);
316 public void initialize() {
317 AndroidDebugBridgeConfiguration currentConfig = getConfigAs(AndroidDebugBridgeConfiguration.class);
318 config = currentConfig;
319 var mediaStateJSONConfig = currentConfig.mediaStateJSONConfig;
320 if (mediaStateJSONConfig != null && !mediaStateJSONConfig.isEmpty()) {
321 loadMediaStateConfig(mediaStateJSONConfig);
323 adbConnection.configure(currentConfig);
324 var androidVersion = thing.getProperties().get(Thing.PROPERTY_FIRMWARE_VERSION);
325 if (androidVersion != null) {
326 // configure android implementation to use
327 adbConnection.setAndroidVersion(androidVersion);
329 updateStatus(ThingStatus.UNKNOWN);
330 connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(this::checkConnection, 0,
331 currentConfig.refreshTime, TimeUnit.SECONDS);
334 private void loadMediaStateConfig(String mediaStateJSONConfig) {
335 List<CommandOption> commandOptions;
337 packageConfigs = GSON.fromJson(mediaStateJSONConfig, AndroidDebugBridgeMediaStatePackageConfig[].class);
338 commandOptions = Arrays.stream(packageConfigs)
339 .map(AndroidDebugBridgeMediaStatePackageConfig::toCommandOption)
340 .collect(Collectors.toUnmodifiableList());
341 } catch (JsonSyntaxException e) {
342 logger.warn("unable to parse media state config: {}", e.getMessage());
343 commandOptions = List.of();
345 commandDescriptionProvider.setCommandOptions(new ChannelUID(getThing().getUID(), START_PACKAGE_CHANNEL),
350 public void dispose() {
351 var schedule = connectionCheckerSchedule;
352 if (schedule != null) {
353 schedule.cancel(true);
354 connectionCheckerSchedule = null;
356 packageConfigs = null;
357 adbConnection.disconnect();
361 public void checkConnection() {
362 AndroidDebugBridgeConfiguration currentConfig = config;
364 logger.debug("Refresh device {} status", currentConfig.ip);
365 if (adbConnection.isConnected()) {
366 if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
367 // refresh properties only on state changes
370 updateStatus(ThingStatus.ONLINE);
374 adbConnection.connect();
375 } catch (AndroidDebugBridgeDeviceException e) {
376 logger.debug("Error connecting to device; [{}]: {}", e.getClass().getCanonicalName(),
378 adbConnection.disconnect();
379 updateStatus(ThingStatus.OFFLINE);
382 if (adbConnection.isConnected()) {
383 updateStatus(ThingStatus.ONLINE);
388 } catch (InterruptedException ignored) {
389 } catch (AndroidDebugBridgeDeviceException | AndroidDebugBridgeDeviceReadException | ExecutionException e) {
390 logger.debug("Connection checker error: {}", e.getMessage());
391 adbConnection.disconnect();
392 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
396 private void refreshProperties() throws InterruptedException, AndroidDebugBridgeDeviceException,
397 AndroidDebugBridgeDeviceReadException, ExecutionException {
398 // Add some information about the device
400 Map<String, String> editProperties = editProperties();
401 editProperties.put(Thing.PROPERTY_SERIAL_NUMBER, adbConnection.getSerialNo());
402 editProperties.put(Thing.PROPERTY_MODEL_ID, adbConnection.getModel());
403 var androidVersion = adbConnection.getAndroidVersion();
404 editProperties.put(Thing.PROPERTY_FIRMWARE_VERSION, androidVersion);
405 // refresh android version to use
406 adbConnection.setAndroidVersion(androidVersion);
407 editProperties.put(Thing.PROPERTY_VENDOR, adbConnection.getBrand());
409 editProperties.put(Thing.PROPERTY_MAC_ADDRESS, adbConnection.getMacAddress());
410 } catch (AndroidDebugBridgeDeviceReadException e) {
411 logger.debug("Refresh properties error: {}", e.getMessage());
413 updateProperties(editProperties);
414 } catch (TimeoutException e) {
415 logger.debug("Refresh properties error: Timeout");
420 private void refreshStatus() throws InterruptedException, AndroidDebugBridgeDeviceException, ExecutionException {
422 boolean prevDeviceAwake = deviceAwake;
424 awakeState = adbConnection.isAwake();
425 deviceAwake = awakeState;
426 } catch (TimeoutException e) {
427 // happen a lot when device is sleeping; abort refresh other channels
428 logger.debug("Unable to refresh awake state: Timeout; aborting channels refresh");
431 var awakeStateChannelUID = new ChannelUID(this.thing.getUID(), AWAKE_STATE_CHANNEL);
432 if (isLinked(awakeStateChannelUID)) {
433 updateState(awakeStateChannelUID, OnOffType.from(awakeState));
435 if (!awakeState && !prevDeviceAwake) {
436 // abort refresh channels while device is sleeping, throws many timeouts
437 logger.debug("device {} is sleeping", config.ip);
441 handleCommandInternal(new ChannelUID(this.thing.getUID(), MEDIA_VOLUME_CHANNEL), RefreshType.REFRESH);
442 } catch (AndroidDebugBridgeDeviceReadException e) {
443 logger.warn("Unable to refresh media volume: {}", e.getMessage());
444 } catch (TimeoutException e) {
445 logger.warn("Unable to refresh media volume: Timeout");
448 handleCommandInternal(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL), RefreshType.REFRESH);
449 } catch (AndroidDebugBridgeDeviceReadException e) {
450 logger.warn("Unable to refresh play status: {}", e.getMessage());
451 } catch (TimeoutException e) {
452 logger.warn("Unable to refresh play status: Timeout");
455 handleCommandInternal(new ChannelUID(this.thing.getUID(), CURRENT_PACKAGE_CHANNEL), RefreshType.REFRESH);
456 } catch (AndroidDebugBridgeDeviceReadException e) {
457 logger.warn("Unable to refresh current package: {}", e.getMessage());
458 } catch (TimeoutException e) {
459 logger.warn("Unable to refresh current package: Timeout");
462 handleCommandInternal(new ChannelUID(this.thing.getUID(), WAKE_LOCK_CHANNEL), RefreshType.REFRESH);
463 } catch (AndroidDebugBridgeDeviceReadException e) {
464 logger.warn("Unable to refresh wake lock: {}", e.getMessage());
465 } catch (TimeoutException e) {
466 logger.warn("Unable to refresh wake lock: Timeout");
469 handleCommandInternal(new ChannelUID(this.thing.getUID(), SCREEN_STATE_CHANNEL), RefreshType.REFRESH);
470 } catch (AndroidDebugBridgeDeviceReadException e) {
471 logger.warn("Unable to refresh screen state: {}", e.getMessage());
472 } catch (TimeoutException e) {
473 logger.warn("Unable to refresh screen state: Timeout");
477 static class AndroidDebugBridgeMediaStatePackageConfig {
478 public String name = "";
479 public @Nullable String label;
480 public String mode = "";
481 public List<Integer> wakeLockPlayStates = List.of();
483 public CommandOption toCommandOption() {
484 return new CommandOption(name, label == null ? name : label);