]> git.basschouten.com Git - openhab-addons.git/blob
ceec6ca78c3f1106e8dafcc673f408da808bad27
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.androiddebugbridge.internal;
14
15 import static org.openhab.binding.androiddebugbridge.internal.AndroidDebugBridgeBindingConstants.*;
16
17 import java.util.Arrays;
18 import java.util.List;
19 import java.util.Map;
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;
26
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;
47
48 import com.google.gson.Gson;
49 import com.google.gson.JsonSyntaxException;
50
51 /**
52  * The {@link AndroidDebugBridgeHandler} is responsible for handling commands, which are
53  * sent to one of the channels.
54  *
55  * @author Miguel Álvarez - Initial contribution
56  */
57 @NonNullByDefault
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);
70
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;
79
80     public AndroidDebugBridgeHandler(Thing thing,
81             AndroidDebugBridgeDynamicCommandDescriptionProvider commandDescriptionProvider) {
82         super(thing);
83         this.commandDescriptionProvider = commandDescriptionProvider;
84         this.adbConnection = new AndroidDebugBridgeDevice(scheduler);
85     }
86
87     @Override
88     public void handleCommand(ChannelUID channelUID, Command command) {
89         AndroidDebugBridgeConfiguration currentConfig = config;
90         try {
91             if (!adbConnection.isConnected()) {
92                 // try reconnect
93                 adbConnection.connect();
94             }
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();
101             }
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();
107         }
108     }
109
110     private void handleCommandInternal(ChannelUID channelUID, Command command)
111             throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
112             TimeoutException, ExecutionException {
113         if (!isLinked(channelUID)) {
114             return;
115         }
116         String channelId = channelUID.getId();
117         switch (channelId) {
118             case KEY_EVENT_CHANNEL:
119                 adbConnection.sendKeyEvent(command.toFullString());
120                 break;
121             case TEXT_CHANNEL:
122                 adbConnection.sendText(command.toFullString());
123                 break;
124             case TAP_CHANNEL:
125                 adbConnection.sendTap(command.toFullString());
126                 break;
127             case URL_CHANNEL:
128                 adbConnection.openUrl(command.toFullString());
129                 break;
130             case MEDIA_VOLUME_CHANNEL:
131                 handleMediaVolume(channelUID, command);
132                 break;
133             case MEDIA_CONTROL_CHANNEL:
134                 handleMediaControlCommand(channelUID, command);
135                 break;
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()));
140                 break;
141             case STOP_PACKAGE_CHANNEL:
142                 adbConnection.stopPackage(command.toFullString());
143                 break;
144             case STOP_CURRENT_PACKAGE_CHANNEL:
145                 if (OnOffType.from(command.toFullString()).equals(OnOffType.OFF)) {
146                     adbConnection.stopPackage(adbConnection.getCurrentPackage());
147                 }
148                 break;
149             case CURRENT_PACKAGE_CHANNEL:
150                 if (command instanceof RefreshType) {
151                     var packageName = adbConnection.getCurrentPackage();
152                     updateState(channelUID, new StringType(packageName));
153                 }
154                 break;
155             case WAKE_LOCK_CHANNEL:
156                 if (command instanceof RefreshType) {
157                     int lock = adbConnection.getPowerWakeLock();
158                     updateState(channelUID, new DecimalType(lock));
159                 }
160                 break;
161             case AWAKE_STATE_CHANNEL:
162                 if (command instanceof RefreshType) {
163                     boolean awakeState = adbConnection.isAwake();
164                     updateState(channelUID, OnOffType.from(awakeState));
165                 }
166                 break;
167             case SCREEN_STATE_CHANNEL:
168                 if (command instanceof RefreshType) {
169                     boolean screenState = adbConnection.isScreenOn();
170                     updateState(channelUID, OnOffType.from(screenState));
171                 }
172                 break;
173             case SHUTDOWN_CHANNEL:
174                 switch (command.toFullString()) {
175                     case SHUTDOWN_POWER_OFF:
176                         adbConnection.powerOffDevice();
177                         updateStatus(ThingStatus.OFFLINE);
178                         break;
179                     case SHUTDOWN_REBOOT:
180                         adbConnection.rebootDevice();
181                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Rebooting");
182                         break;
183                 }
184                 break;
185             case START_INTENT_CHANNEL:
186                 if (command instanceof RefreshType) {
187                     return;
188                 }
189                 adbConnection.startIntent(command.toFullString());
190                 break;
191             case RECORD_INPUT_CHANNEL:
192                 recordDeviceInput(command);
193                 break;
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);
199                 }
200                 break;
201         }
202         consecutiveTimeouts = 0;
203     }
204
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 '_'.");
210             return;
211         }
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);
221             }
222         } else {
223             updateProperty(recordPropertyName, eventCommand);
224             logger.debug("New record {}: {}", recordName, eventCommand);
225         }
226     }
227
228     private String getRecordPropertyName(String recordName) {
229         return String.format("input-record:%s", recordName);
230     }
231
232     private String getRecordPropertyName(Command recordNameCommand) {
233         return getRecordPropertyName(recordNameCommand.toFullString());
234     }
235
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);
253         } else {
254             if (maxMediaVolume == 0) {
255                 return; // We can not transform percentage
256             }
257             int targetVolume = Integer.parseInt(command.toFullString());
258             adbConnection.setMediaVolume((int) Math.round(fromPercent(targetVolume, maxMediaVolume)));
259             updateState(channelUID, new PercentType(targetVolume));
260         }
261     }
262
263     private double toPercent(double value, double maxValue) {
264         return (value / maxValue) * 100;
265     }
266
267     private double fromPercent(double percent, double maxValue) {
268         return (percent / 100) * maxValue;
269     }
270
271     private void handleMediaControlCommand(ChannelUID channelUID, Command command)
272             throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
273             TimeoutException, ExecutionException {
274         if (command instanceof RefreshType) {
275             boolean playing;
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) {
282                     case "idle":
283                         playing = false;
284                         break;
285                     case "wake_lock":
286                         int wakeLockState = adbConnection.getPowerWakeLock();
287                         playing = currentPackageConfig.wakeLockPlayStates.contains(wakeLockState);
288                         break;
289                     case "media_state":
290                         playing = adbConnection.isPlayingMedia(currentPackage);
291                         break;
292                     case "audio":
293                         playing = adbConnection.isPlayingAudio();
294                         break;
295                     default:
296                         logger.warn("media state config: package {} unsupported mode", currentPackage);
297                         playing = false;
298                 }
299             } else {
300                 logger.debug("media stream config not found for {}", currentPackage);
301                 playing = adbConnection.isPlayingMedia(currentPackage);
302             }
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);
311             }
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);
317             }
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);
323             }
324         } else {
325             logger.warn("Unknown media control command: {}", command);
326         }
327     }
328
329     @Override
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);
336         }
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);
342         }
343         updateStatus(ThingStatus.UNKNOWN);
344         connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(this::checkConnection, 0,
345                 currentConfig.refreshTime, TimeUnit.SECONDS);
346     }
347
348     private void loadMediaStateConfig(String mediaStateJSONConfig) {
349         List<CommandOption> commandOptions;
350         try {
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();
358         }
359         commandDescriptionProvider.setCommandOptions(new ChannelUID(getThing().getUID(), START_PACKAGE_CHANNEL),
360                 commandOptions);
361     }
362
363     @Override
364     public void dispose() {
365         var schedule = connectionCheckerSchedule;
366         if (schedule != null) {
367             schedule.cancel(true);
368             connectionCheckerSchedule = null;
369         }
370         packageConfigs = null;
371         adbConnection.disconnect();
372         super.dispose();
373     }
374
375     public void checkConnection() {
376         AndroidDebugBridgeConfiguration currentConfig = config;
377         try {
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
382                     refreshProperties();
383                 }
384                 updateStatus(ThingStatus.ONLINE);
385                 refreshStatus();
386             } else {
387                 try {
388                     adbConnection.connect();
389                 } catch (AndroidDebugBridgeDeviceException e) {
390                     logger.debug("Error connecting to device; [{}]: {}", e.getClass().getCanonicalName(),
391                             e.getMessage());
392                     adbConnection.disconnect();
393                     updateStatus(ThingStatus.OFFLINE);
394                     return;
395                 }
396                 if (adbConnection.isConnected()) {
397                     updateStatus(ThingStatus.ONLINE);
398                     refreshProperties();
399                     refreshStatus();
400                 }
401             }
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());
407         }
408     }
409
410     private void refreshProperties() throws InterruptedException, AndroidDebugBridgeDeviceException,
411             AndroidDebugBridgeDeviceReadException, ExecutionException {
412         // Add some information about the device
413         try {
414             Map<String, String> editProperties = editProperties();
415             try {
416                 editProperties.put(Thing.PROPERTY_SERIAL_NUMBER, adbConnection.getSerialNo());
417             } catch (AndroidDebugBridgeDeviceReadException ignored) {
418                 // Allow devices without serial number.
419             }
420             try {
421                 editProperties.put(Thing.PROPERTY_MODEL_ID, adbConnection.getModel());
422             } catch (AndroidDebugBridgeDeviceReadException ignored) {
423                 // Allow devices without model id.
424             }
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());
430             try {
431                 editProperties.put(Thing.PROPERTY_MAC_ADDRESS, adbConnection.getMacAddress());
432             } catch (AndroidDebugBridgeDeviceReadException e) {
433                 logger.debug("Refresh properties error: {}", e.getMessage());
434             }
435             updateProperties(editProperties);
436         } catch (TimeoutException e) {
437             logger.debug("Refresh properties error: Timeout");
438             return;
439         }
440     }
441
442     private void refreshStatus() throws InterruptedException, AndroidDebugBridgeDeviceException, ExecutionException {
443         boolean awakeState;
444         boolean prevDeviceAwake = deviceAwake;
445         try {
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();
452             return;
453         }
454         consecutiveTimeouts = 0;
455         var awakeStateChannelUID = new ChannelUID(this.thing.getUID(), AWAKE_STATE_CHANNEL);
456         if (isLinked(awakeStateChannelUID)) {
457             updateState(awakeStateChannelUID, OnOffType.from(awakeState));
458         }
459         if (!awakeState && !prevDeviceAwake) {
460             // abort refresh channels while device is sleeping, throws many timeouts
461             logger.debug("device {} is sleeping", config.ip);
462             return;
463         }
464         try {
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");
470         }
471         try {
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");
477         }
478         try {
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");
484         }
485         try {
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");
491         }
492         try {
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");
498         }
499     }
500
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();
506             checkConnection();
507             consecutiveTimeouts = 0;
508         }
509     }
510
511     static class AndroidDebugBridgeMediaStatePackageConfig {
512         public String name = "";
513         public @Nullable String label;
514         public String mode = "";
515         public List<Integer> wakeLockPlayStates = List.of();
516
517         public CommandOption toCommandOption() {
518             return new CommandOption(name, label == null ? name : label);
519         }
520     }
521 }