]> git.basschouten.com Git - openhab-addons.git/blob
69ac4cb5a1bfc0016a99c2255c04e0758026424a
[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.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;
46
47 import com.google.gson.Gson;
48 import com.google.gson.JsonSyntaxException;
49
50 /**
51  * The {@link AndroidDebugBridgeHandler} is responsible for handling commands, which are
52  * sent to one of the channels.
53  *
54  * @author Miguel Álvarez - Initial contribution
55  */
56 @NonNullByDefault
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);
69
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;
77
78     public AndroidDebugBridgeHandler(Thing thing,
79             AndroidDebugBridgeDynamicCommandDescriptionProvider commandDescriptionProvider) {
80         super(thing);
81         this.commandDescriptionProvider = commandDescriptionProvider;
82         this.adbConnection = new AndroidDebugBridgeDevice(scheduler);
83     }
84
85     @Override
86     public void handleCommand(ChannelUID channelUID, Command command) {
87         AndroidDebugBridgeConfiguration currentConfig = config;
88         try {
89             if (!adbConnection.isConnected()) {
90                 // try reconnect
91                 adbConnection.connect();
92             }
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();
99             }
100         } catch (AndroidDebugBridgeDeviceReadException e) {
101             logger.warn("{} - read error: {}", currentConfig.ip, e.getMessage());
102         } catch (TimeoutException e) {
103             logger.warn("{} - timeout error", currentConfig.ip);
104         }
105     }
106
107     private void handleCommandInternal(ChannelUID channelUID, Command command)
108             throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
109             TimeoutException, ExecutionException {
110         if (!isLinked(channelUID)) {
111             return;
112         }
113         String channelId = channelUID.getId();
114         switch (channelId) {
115             case KEY_EVENT_CHANNEL:
116                 adbConnection.sendKeyEvent(command.toFullString());
117                 break;
118             case TEXT_CHANNEL:
119                 adbConnection.sendText(command.toFullString());
120                 break;
121             case TAP_CHANNEL:
122                 adbConnection.sendTap(command.toFullString());
123                 break;
124             case URL_CHANNEL:
125                 adbConnection.openUrl(command.toFullString());
126                 break;
127             case MEDIA_VOLUME_CHANNEL:
128                 handleMediaVolume(channelUID, command);
129                 break;
130             case MEDIA_CONTROL_CHANNEL:
131                 handleMediaControlCommand(channelUID, command);
132                 break;
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()));
137                 break;
138             case STOP_PACKAGE_CHANNEL:
139                 adbConnection.stopPackage(command.toFullString());
140                 break;
141             case STOP_CURRENT_PACKAGE_CHANNEL:
142                 if (OnOffType.from(command.toFullString()).equals(OnOffType.OFF)) {
143                     adbConnection.stopPackage(adbConnection.getCurrentPackage());
144                 }
145                 break;
146             case CURRENT_PACKAGE_CHANNEL:
147                 if (command instanceof RefreshType) {
148                     var packageName = adbConnection.getCurrentPackage();
149                     updateState(channelUID, new StringType(packageName));
150                 }
151                 break;
152             case WAKE_LOCK_CHANNEL:
153                 if (command instanceof RefreshType) {
154                     int lock = adbConnection.getPowerWakeLock();
155                     updateState(channelUID, new DecimalType(lock));
156                 }
157                 break;
158             case AWAKE_STATE_CHANNEL:
159                 if (command instanceof RefreshType) {
160                     boolean awakeState = adbConnection.isAwake();
161                     updateState(channelUID, OnOffType.from(awakeState));
162                 }
163                 break;
164             case SCREEN_STATE_CHANNEL:
165                 if (command instanceof RefreshType) {
166                     boolean screenState = adbConnection.isScreenOn();
167                     updateState(channelUID, OnOffType.from(screenState));
168                 }
169                 break;
170             case SHUTDOWN_CHANNEL:
171                 switch (command.toFullString()) {
172                     case SHUTDOWN_POWER_OFF:
173                         adbConnection.powerOffDevice();
174                         updateStatus(ThingStatus.OFFLINE);
175                         break;
176                     case SHUTDOWN_REBOOT:
177                         adbConnection.rebootDevice();
178                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Rebooting");
179                         break;
180                 }
181                 break;
182             case START_INTENT_CHANNEL:
183                 if (command instanceof RefreshType) {
184                     return;
185                 }
186                 adbConnection.startIntent(command.toFullString());
187                 break;
188             case RECORD_INPUT_CHANNEL:
189                 recordDeviceInput(command);
190                 break;
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);
196                 }
197                 break;
198         }
199     }
200
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 '_'.");
206             return;
207         }
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);
217             }
218         } else {
219             updateProperty(recordPropertyName, eventCommand);
220             logger.debug("New record {}: {}", recordName, eventCommand);
221         }
222     }
223
224     private String getRecordPropertyName(String recordName) {
225         return String.format("input-record:%s", recordName);
226     }
227
228     private String getRecordPropertyName(Command recordNameCommand) {
229         return getRecordPropertyName(recordNameCommand.toFullString());
230     }
231
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))));
239         } else {
240             if (maxMediaVolume == 0) {
241                 return; // We can not transform percentage
242             }
243             int targetVolume = Integer.parseInt(command.toFullString());
244             adbConnection.setMediaVolume((int) Math.round(fromPercent(targetVolume, maxMediaVolume)));
245             updateState(channelUID, new PercentType(targetVolume));
246         }
247     }
248
249     private double toPercent(double value, double maxValue) {
250         return (value / maxValue) * 100;
251     }
252
253     private double fromPercent(double value, double maxValue) {
254         return (value / 100) * maxValue;
255     }
256
257     private void handleMediaControlCommand(ChannelUID channelUID, Command command)
258             throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException,
259             TimeoutException, ExecutionException {
260         if (command instanceof RefreshType) {
261             boolean playing;
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) {
268                     case "idle":
269                         playing = false;
270                         break;
271                     case "wake_lock":
272                         int wakeLockState = adbConnection.getPowerWakeLock();
273                         playing = currentPackageConfig.wakeLockPlayStates.contains(wakeLockState);
274                         break;
275                     case "media_state":
276                         playing = adbConnection.isPlayingMedia(currentPackage);
277                         break;
278                     case "audio":
279                         playing = adbConnection.isPlayingAudio();
280                         break;
281                     default:
282                         logger.warn("media state config: package {} unsupported mode", currentPackage);
283                         playing = false;
284                 }
285             } else {
286                 logger.debug("media stream config not found for {}", currentPackage);
287                 playing = adbConnection.isPlayingMedia(currentPackage);
288             }
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);
297             }
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);
303             }
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);
309             }
310         } else {
311             logger.warn("Unknown media control command: {}", command);
312         }
313     }
314
315     @Override
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);
322         }
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);
328         }
329         updateStatus(ThingStatus.UNKNOWN);
330         connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(this::checkConnection, 0,
331                 currentConfig.refreshTime, TimeUnit.SECONDS);
332     }
333
334     private void loadMediaStateConfig(String mediaStateJSONConfig) {
335         List<CommandOption> commandOptions;
336         try {
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();
344         }
345         commandDescriptionProvider.setCommandOptions(new ChannelUID(getThing().getUID(), START_PACKAGE_CHANNEL),
346                 commandOptions);
347     }
348
349     @Override
350     public void dispose() {
351         var schedule = connectionCheckerSchedule;
352         if (schedule != null) {
353             schedule.cancel(true);
354             connectionCheckerSchedule = null;
355         }
356         packageConfigs = null;
357         adbConnection.disconnect();
358         super.dispose();
359     }
360
361     public void checkConnection() {
362         AndroidDebugBridgeConfiguration currentConfig = config;
363         try {
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
368                     refreshProperties();
369                 }
370                 updateStatus(ThingStatus.ONLINE);
371                 refreshStatus();
372             } else {
373                 try {
374                     adbConnection.connect();
375                 } catch (AndroidDebugBridgeDeviceException e) {
376                     logger.debug("Error connecting to device; [{}]: {}", e.getClass().getCanonicalName(),
377                             e.getMessage());
378                     adbConnection.disconnect();
379                     updateStatus(ThingStatus.OFFLINE);
380                     return;
381                 }
382                 if (adbConnection.isConnected()) {
383                     updateStatus(ThingStatus.ONLINE);
384                     refreshProperties();
385                     refreshStatus();
386                 }
387             }
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());
393         }
394     }
395
396     private void refreshProperties() throws InterruptedException, AndroidDebugBridgeDeviceException,
397             AndroidDebugBridgeDeviceReadException, ExecutionException {
398         // Add some information about the device
399         try {
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());
408             try {
409                 editProperties.put(Thing.PROPERTY_MAC_ADDRESS, adbConnection.getMacAddress());
410             } catch (AndroidDebugBridgeDeviceReadException e) {
411                 logger.debug("Refresh properties error: {}", e.getMessage());
412             }
413             updateProperties(editProperties);
414         } catch (TimeoutException e) {
415             logger.debug("Refresh properties error: Timeout");
416             return;
417         }
418     }
419
420     private void refreshStatus() throws InterruptedException, AndroidDebugBridgeDeviceException, ExecutionException {
421         boolean awakeState;
422         boolean prevDeviceAwake = deviceAwake;
423         try {
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");
429             return;
430         }
431         var awakeStateChannelUID = new ChannelUID(this.thing.getUID(), AWAKE_STATE_CHANNEL);
432         if (isLinked(awakeStateChannelUID)) {
433             updateState(awakeStateChannelUID, OnOffType.from(awakeState));
434         }
435         if (!awakeState && !prevDeviceAwake) {
436             // abort refresh channels while device is sleeping, throws many timeouts
437             logger.debug("device {} is sleeping", config.ip);
438             return;
439         }
440         try {
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");
446         }
447         try {
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");
453         }
454         try {
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");
460         }
461         try {
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");
467         }
468         try {
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");
474         }
475     }
476
477     static class AndroidDebugBridgeMediaStatePackageConfig {
478         public String name = "";
479         public @Nullable String label;
480         public String mode = "";
481         public List<Integer> wakeLockPlayStates = List.of();
482
483         public CommandOption toCommandOption() {
484             return new CommandOption(name, label == null ? name : label);
485         }
486     }
487 }