From a693a4ed643e2f33d90c449eb2c4deecb2d48948 Mon Sep 17 00:00:00 2001 From: GiviMAD Date: Thu, 21 Jan 2021 11:11:50 +0100 Subject: [PATCH] [androiddebugbridge] Initial contribution (#9259) * Initial contribution Signed-off-by: Miguel --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../NOTICE | 19 + .../README.md | 375 +++++++++++++++++ .../pom.xml | 26 ++ .../src/main/feature/feature.xml | 23 + ...ndroidDebugBridgeBindingConfiguration.java | 40 ++ .../AndroidDebugBridgeBindingConstants.java | 50 +++ .../AndroidDebugBridgeConfiguration.java | 45 ++ .../internal/AndroidDebugBridgeDevice.java | 349 +++++++++++++++ .../AndroidDebugBridgeDeviceException.java | 29 ++ ...AndroidDebugBridgeDeviceReadException.java | 29 ++ .../AndroidDebugBridgeDiscoveryService.java | 181 ++++++++ .../internal/AndroidDebugBridgeHandler.java | 339 +++++++++++++++ .../AndroidDebugBridgeHandlerFactory.java | 49 +++ .../main/resources/OH-INF/binding/binding.xml | 31 ++ .../resources/OH-INF/thing/thing-types.xml | 396 ++++++++++++++++++ bundles/pom.xml | 1 + 18 files changed, 1988 insertions(+) create mode 100644 bundles/org.openhab.binding.androiddebugbridge/NOTICE create mode 100644 bundles/org.openhab.binding.androiddebugbridge/README.md create mode 100644 bundles/org.openhab.binding.androiddebugbridge/pom.xml create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConfiguration.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConstants.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeConfiguration.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDevice.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceException.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceReadException.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDiscoveryService.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandler.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandlerFactory.java create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/thing/thing-types.xml diff --git a/CODEOWNERS b/CODEOWNERS index e6e9168c9cfd5..1f5619a95051f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -16,6 +16,7 @@ /bundles/org.openhab.binding.amazondashbutton/ @OLibutzki /bundles/org.openhab.binding.amazonechocontrol/ @mgeramb /bundles/org.openhab.binding.ambientweather/ @mhilbush +/bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD /bundles/org.openhab.binding.astro/ @gerrieg /bundles/org.openhab.binding.atlona/ @tmrobert8 /bundles/org.openhab.binding.autelis/ @digitaldan diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index cdc57aa16edd3..b8219428eec77 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -71,6 +71,11 @@ org.openhab.binding.ambientweather ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.androiddebugbridge + ${project.version} + org.openhab.addons.bundles org.openhab.binding.astro diff --git a/bundles/org.openhab.binding.androiddebugbridge/NOTICE b/bundles/org.openhab.binding.androiddebugbridge/NOTICE new file mode 100644 index 0000000000000..64e1e949015bc --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/NOTICE @@ -0,0 +1,19 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons + +== Third-party Content + +adblib +* License: BSD 3-Clause +* Source: https://github.com/tananaev/adblib diff --git a/bundles/org.openhab.binding.androiddebugbridge/README.md b/bundles/org.openhab.binding.androiddebugbridge/README.md new file mode 100644 index 0000000000000..9b1ce5c0dace0 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/README.md @@ -0,0 +1,375 @@ +# Android Debug Bridge Binding + +This binding allows to connect to android devices through the adb protocol. +The device needs to have **usb debugging enabled** and **allow debugging over tcp**, some devices allow to enable this in the device options but others need a previous connection through adb or even be rooted. +If you are not familiar with adb I suggest you to search "How to enable adb over wifi on \" or something like that. + +## Supported Things + +This binding was tested on the Fire TV Stick (android version 7.1.2, volume control not working) and Nexus5x (android version 8.1.0, everything works nice), please update this document if you tested it with other android versions to reflect the compatibility of the biding. + +## Discovery + +As I can not find a way to identify android devices in the network the discovery will try to connect through adb to all the reachable ip in the defined range, you could customize the discovery process through the binding options. **Your device will prop a message requesting you to authorize the connection, you should check the option "Always allow connections from this device" (or something similar) and accept**. + +## Binding Configuration + +| Config | Type | description | +|----------|----------|------------------------------| +| discoveryPort | int | Port used on discovery to connect to the device through adb | +| discoveryReachableMs | int | Milliseconds to wait while discovering to determine if the ip is reachable | +| discoveryIpRangeMin | int | Used to limit the number of IPs checked while discovering | +| discoveryIpRangeMax | int | Used to limit the number of IPs checked while discovering | + +## Thing Configuration + +| ThingTypeID | description | +|----------|------------------------------| +| android | Android device | + +| Config | Type | description | +|----------|----------|------------------------------| +| ip | String | Device ip address | +| port | int | Device port listening to adb connections (default: 5555) | +| refreshTime | int | Seconds between device status refreshes (default: 30) | +| timeout | int | Command timeout in seconds (default: 5) | +| mediaStateJSONConfig | String | Expects a JSON array. Allow to configure the media state detection method per app. Described in the following section | + +## Media State Detection + +You can configure different modes to detect when the device is playing media depending on the current app. + +The available modes are: + +* idle: assert not playing, avoid command execution. +* media_state: detect play status by dumping the media_session service. This is the default for not configured apps +* audio: detect play status by dumping the audio service. +* wake_lock: detect play status by comparing the power wake lock state with the values provided in 'wakeLockPlayStates' + +The configuration depends on the application, device and version used. + +This is a sample of the mediaStateJSONConfig thing configuration: + +`[{"name": "com.amazon.tv.launcher", "mode": "idle"},{"name": "org.jellyfin.androidtv", "mode": "wake_lock", "wakeLockPlayStates": [2,3]},{"name": "com.amazon.firetv.youtube", "mode": "wake_lock", "wakeLockPlayStates": [2]}]` + +## Channels + +| channel | type | description | +|----------|--------|------------------------------| +| key-event | String | Send key event to android device. Possible values listed below | +| text | String | Send text to android device | +| media-volume | Dimmer | Set or get media volume level on android device | +| media-control | Player | Control media on android device | +| start-package | String | Run application by package name | +| stop-package | String | Stop application by package name | +| current-package | String | Package name of the top application in screen | +| wake-lock | Number | Power wake lock value | +| screen-state | Switch | Screen power state | + +#### Available key-event values: + +* KEYCODE_0 +* KEYCODE_1 +* KEYCODE_11 +* KEYCODE_12 +* KEYCODE_2 +* KEYCODE_3 +* KEYCODE_3D_MODE +* KEYCODE_4 +* KEYCODE_5 +* KEYCODE_6 +* KEYCODE_7 +* KEYCODE_8 +* KEYCODE_9 +* KEYCODE_A +* KEYCODE_ALL_APPS +* KEYCODE_ALT_LEFT +* KEYCODE_ALT_RIGHT +* KEYCODE_APOSTROPHE +* KEYCODE_APP_SWITCH +* KEYCODE_ASSIST +* KEYCODE_AT +* KEYCODE_AVR_INPUT +* KEYCODE_AVR_POWER +* KEYCODE_B +* KEYCODE_BACK +* KEYCODE_BACKSLASH +* KEYCODE_BOOKMARK +* KEYCODE_BREAK +* KEYCODE_BRIGHTNESS_DOWN +* KEYCODE_BRIGHTNESS_UP +* KEYCODE_BUTTON_1 +* KEYCODE_BUTTON_10 +* KEYCODE_BUTTON_11 +* KEYCODE_BUTTON_12 +* KEYCODE_BUTTON_13 +* KEYCODE_BUTTON_14 +* KEYCODE_BUTTON_15 +* KEYCODE_BUTTON_16 +* KEYCODE_BUTTON_2 +* KEYCODE_BUTTON_3 +* KEYCODE_BUTTON_4 +* KEYCODE_BUTTON_5 +* KEYCODE_BUTTON_6 +* KEYCODE_BUTTON_7 +* KEYCODE_BUTTON_8 +* KEYCODE_BUTTON_9 +* KEYCODE_BUTTON_A +* KEYCODE_BUTTON_B +* KEYCODE_BUTTON_C +* KEYCODE_BUTTON_L1 +* KEYCODE_BUTTON_L2 +* KEYCODE_BUTTON_MODE +* KEYCODE_BUTTON_R1 +* KEYCODE_BUTTON_R2 +* KEYCODE_BUTTON_SELECT +* KEYCODE_BUTTON_START +* KEYCODE_BUTTON_THUMBL +* KEYCODE_BUTTON_THUMBR +* KEYCODE_BUTTON_X +* KEYCODE_BUTTON_Y +* KEYCODE_BUTTON_Z +* KEYCODE_C +* KEYCODE_CALCULATOR +* KEYCODE_CALENDAR +* KEYCODE_CALL +* KEYCODE_CAMERA +* KEYCODE_CAPS_LOCK +* KEYCODE_CAPTIONS +* KEYCODE_CHANNEL_DOWN +* KEYCODE_CHANNEL_UP +* KEYCODE_CLEAR +* KEYCODE_COMMA +* KEYCODE_CONTACTS +* KEYCODE_COPY +* KEYCODE_CTRL_LEFT +* KEYCODE_CTRL_RIGHT +* KEYCODE_CUT +* KEYCODE_D +* KEYCODE_DEL +* KEYCODE_DPAD_CENTER +* KEYCODE_DPAD_DOWN +* KEYCODE_DPAD_DOWN_LEFT +* KEYCODE_DPAD_DOWN_RIGHT +* KEYCODE_DPAD_LEFT +* KEYCODE_DPAD_RIGHT +* KEYCODE_DPAD_UP +* KEYCODE_DPAD_UP_LEFT +* KEYCODE_DPAD_UP_RIGHT +* KEYCODE_DVR +* KEYCODE_E +* KEYCODE_EISU +* KEYCODE_ENDCALL +* KEYCODE_ENTER +* KEYCODE_ENVELOPE +* KEYCODE_EQUALS +* KEYCODE_ESCAPE +* KEYCODE_EXPLORER +* KEYCODE_F +* KEYCODE_F1 +* KEYCODE_F10 +* KEYCODE_F11 +* KEYCODE_F12 +* KEYCODE_F2 +* KEYCODE_F3 +* KEYCODE_F4 +* KEYCODE_F5 +* KEYCODE_F6 +* KEYCODE_F7 +* KEYCODE_F8 +* KEYCODE_F9 +* KEYCODE_FOCUS +* KEYCODE_FORWARD +* KEYCODE_FORWARD_DEL +* KEYCODE_FUNCTION +* KEYCODE_G +* KEYCODE_GRAVE +* KEYCODE_GUIDE +* KEYCODE_H +* KEYCODE_HEADSETHOOK +* KEYCODE_HELP +* KEYCODE_HENKAN +* KEYCODE_HOME +* KEYCODE_I +* KEYCODE_INFO +* KEYCODE_INSERT +* KEYCODE_J +* KEYCODE_K +* KEYCODE_KANA +* KEYCODE_KATAKANA_HIRAGANA +* KEYCODE_L +* KEYCODE_LANGUAGE_SWITCH +* KEYCODE_LAST_CHANNEL +* KEYCODE_LEFT_BRACKET +* KEYCODE_M +* KEYCODE_MANNER_MODE +* KEYCODE_MEDIA_AUDIO_TRACK +* KEYCODE_MEDIA_CLOSE +* KEYCODE_MEDIA_EJECT +* KEYCODE_MEDIA_FAST_FORWARD +* KEYCODE_MEDIA_NEXT +* KEYCODE_MEDIA_PAUSE +* KEYCODE_MEDIA_PLAY +* KEYCODE_MEDIA_PLAY_PAUSE +* KEYCODE_MEDIA_PREVIOUS +* KEYCODE_MEDIA_RECORD +* KEYCODE_MEDIA_REWIND +* KEYCODE_MEDIA_SKIP_BACKWARD +* KEYCODE_MEDIA_SKIP_FORWARD +* KEYCODE_MEDIA_STEP_BACKWARD +* KEYCODE_MEDIA_STEP_FORWARD +* KEYCODE_MEDIA_STOP +* KEYCODE_MEDIA_TOP_MENU +* KEYCODE_MENU +* KEYCODE_META_LEFT +* KEYCODE_META_RIGHT +* KEYCODE_MINUS +* KEYCODE_MOVE_END +* KEYCODE_MOVE_HOME +* KEYCODE_MUHENKAN +* KEYCODE_MUSIC +* KEYCODE_MUTE +* KEYCODE_N +* KEYCODE_NAVIGATE_IN +* KEYCODE_NAVIGATE_NEXT +* KEYCODE_NAVIGATE_OUT +* KEYCODE_NAVIGATE_PREVIOUS +* KEYCODE_NOTIFICATION +* KEYCODE_NUM +* KEYCODE_NUMPAD_0 +* KEYCODE_NUMPAD_1 +* KEYCODE_NUMPAD_2 +* KEYCODE_NUMPAD_3 +* KEYCODE_NUMPAD_4 +* KEYCODE_NUMPAD_5 +* KEYCODE_NUMPAD_6 +* KEYCODE_NUMPAD_7 +* KEYCODE_NUMPAD_8 +* KEYCODE_NUMPAD_9 +* KEYCODE_NUMPAD_ADD +* KEYCODE_NUMPAD_COMMA +* KEYCODE_NUMPAD_DIVIDE +* KEYCODE_NUMPAD_DOT +* KEYCODE_NUMPAD_ENTER +* KEYCODE_NUMPAD_EQUALS +* KEYCODE_NUMPAD_LEFT_PAREN +* KEYCODE_NUMPAD_MULTIPLY +* KEYCODE_NUMPAD_RIGHT_PAREN +* KEYCODE_NUMPAD_SUBTRACT +* KEYCODE_NUM_LOCK +* KEYCODE_O +* KEYCODE_P +* KEYCODE_PAGE_DOWN +* KEYCODE_PAGE_UP +* KEYCODE_PAIRING +* KEYCODE_PASTE +* KEYCODE_PERIOD +* KEYCODE_PICTSYMBOLS +* KEYCODE_PLUS +* KEYCODE_POUND +* KEYCODE_POWER +* KEYCODE_PROFILE_SWITCH +* KEYCODE_PROG_BLUE +* KEYCODE_PROG_GREEN +* KEYCODE_PROG_RED +* KEYCODE_PROG_YELLOW +* KEYCODE_Q +* KEYCODE_R +* KEYCODE_REFRESH +* KEYCODE_RIGHT_BRACKET +* KEYCODE_RO +* KEYCODE_S +* KEYCODE_SCROLL_LOCK +* KEYCODE_SEARCH +* KEYCODE_SEMICOLON +* KEYCODE_SETTINGS +* KEYCODE_SHIFT_LEFT +* KEYCODE_SHIFT_RIGHT +* KEYCODE_SLASH +* KEYCODE_SLEEP +* KEYCODE_SOFT_LEFT +* KEYCODE_SOFT_RIGHT +* KEYCODE_SOFT_SLEEP +* KEYCODE_SPACE +* KEYCODE_STAR +* KEYCODE_STB_INPUT +* KEYCODE_STB_POWER +* KEYCODE_STEM_1 +* KEYCODE_STEM_2 +* KEYCODE_STEM_3 +* KEYCODE_STEM_PRIMARY +* KEYCODE_SWITCH_CHARSET +* KEYCODE_SYM +* KEYCODE_SYSRQ +* KEYCODE_SYSTEM_NAVIGATION_DOWN +* KEYCODE_SYSTEM_NAVIGATION_LEFT +* KEYCODE_SYSTEM_NAVIGATION_RIGHT +* KEYCODE_SYSTEM_NAVIGATION_UP +* KEYCODE_T +* KEYCODE_TAB +* KEYCODE_THUMBS_DOWN +* KEYCODE_THUMBS_UP +* KEYCODE_TV +* KEYCODE_TV_ANTENNA_CABLE +* KEYCODE_TV_AUDIO_DESCRIPTION +* KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN +* KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP +* KEYCODE_TV_CONTENTS_MENU +* KEYCODE_TV_DATA_SERVICE +* KEYCODE_TV_INPUT +* KEYCODE_TV_INPUT_COMPONENT_1 +* KEYCODE_TV_INPUT_COMPONENT_2 +* KEYCODE_TV_INPUT_COMPOSITE_1 +* KEYCODE_TV_INPUT_COMPOSITE_2 +* KEYCODE_TV_INPUT_HDMI_1 +* KEYCODE_TV_INPUT_HDMI_2 +* KEYCODE_TV_INPUT_HDMI_3 +* KEYCODE_TV_INPUT_HDMI_4 +* KEYCODE_TV_INPUT_VGA_1 +* KEYCODE_TV_MEDIA_CONTEXT_MENU +* KEYCODE_TV_NETWORK +* KEYCODE_TV_NUMBER_ENTRY +* KEYCODE_TV_POWER +* KEYCODE_TV_RADIO_SERVICE +* KEYCODE_TV_SATELLITE +* KEYCODE_TV_SATELLITE_BS +* KEYCODE_TV_SATELLITE_CS +* KEYCODE_TV_SATELLITE_SERVICE +* KEYCODE_TV_TELETEXT +* KEYCODE_TV_TERRESTRIAL_ANALOG +* KEYCODE_TV_TERRESTRIAL_DIGITAL +* KEYCODE_TV_TIMER_PROGRAMMING +* KEYCODE_TV_ZOOM_MODE +* KEYCODE_U +* KEYCODE_UNKNOWN +* KEYCODE_V +* KEYCODE_VOICE_ASSIST +* KEYCODE_VOLUME_DOWN +* KEYCODE_VOLUME_MUTE +* KEYCODE_VOLUME_UP +* KEYCODE_W +* KEYCODE_WAKEUP +* KEYCODE_WINDOW +* KEYCODE_X +* KEYCODE_Y +* KEYCODE_YEN +* KEYCODE_Z +* KEYCODE_ZENKAKU_HANKAKU +* KEYCODE_ZOOM_IN +* KEYCODE_ZOOM_OUT + +## Full Example + +### Sample Thing + +``` +Thing androiddebugbridge:android:xxxxxxxxxxxx [ ip="192.168.1.10" port=5555 refreshTime=30 ] +``` + +### Sample Items + +``` +Group androidDevice "Android TV" +String device_SendKey "Send Key" (androidDevice) { channel="androiddebugbridge:android:xxxxxxxxxxxx:key-event" } +String device_CurrentApp "Current App" (androidDevice) { channel="androiddebugbridge:android:xxxxxxxxxxxx:current-package" } +``` diff --git a/bundles/org.openhab.binding.androiddebugbridge/pom.xml b/bundles/org.openhab.binding.androiddebugbridge/pom.xml new file mode 100644 index 0000000000000..dfeb14d0ebce1 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/pom.xml @@ -0,0 +1,26 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.1.0-SNAPSHOT + + + org.openhab.binding.androiddebugbridge + + openHAB Add-ons :: Bundles :: Android Debug Bridge Binding + + + + com.tananaev + adblib + 1.3 + compile + + + + diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/feature/feature.xml b/bundles/org.openhab.binding.androiddebugbridge/src/main/feature/feature.xml new file mode 100644 index 0000000000000..2de78f45d7727 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/feature/feature.xml @@ -0,0 +1,23 @@ + + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.androiddebugbridge/${project.version} + + diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConfiguration.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConfiguration.java new file mode 100644 index 0000000000000..9d35f11161a88 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConfiguration.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AndroidDebugBridgeConfiguration} class contains fields mapping binding configuration parameters. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeBindingConfiguration { + /** + * Port used on discovery. + */ + public int discoveryPort = 5555; + /** + * Discovery reachable timeout. + */ + public int discoveryReachableMs = 3000; + /** + * Discovery from ip index. + */ + public int discoveryIpRangeMin = 0; + /** + * Discovery to ip index. + */ + public int discoveryIpRangeMax = 255; +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConstants.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConstants.java new file mode 100644 index 0000000000000..de2d31d64d923 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeBindingConstants.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import java.util.Collections; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link AndroidDebugBridgeBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeBindingConstants { + + private static final String BINDING_ID = "androiddebugbridge"; + public static final String BINDING_CONFIGURATION_PID = "binding.androiddebugbridge"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_ANDROID_DEVICE = new ThingTypeUID(BINDING_ID, "android"); + public static final Set SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_ANDROID_DEVICE); + // List of all Channel ids + public static final String KEY_EVENT_CHANNEL = "key-event"; + public static final String TEXT_CHANNEL = "text"; + public static final String MEDIA_VOLUME_CHANNEL = "media-volume"; + public static final String MEDIA_CONTROL_CHANNEL = "media-control"; + public static final String START_PACKAGE_CHANNEL = "start-package"; + public static final String STOP_PACKAGE_CHANNEL = "stop-package"; + public static final String STOP_CURRENT_PACKAGE_CHANNEL = "stop-current-package"; + public static final String CURRENT_PACKAGE_CHANNEL = "current-package"; + public static final String WAKE_LOCK_CHANNEL = "wake-lock"; + public static final String SCREEN_STATE_CHANNEL = "screen-state"; + // List of all Parameters + public static final String PARAMETER_IP = "ip"; + public static final String PARAMETER_PORT = "port"; +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeConfiguration.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeConfiguration.java new file mode 100644 index 0000000000000..17f23029d8cbd --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeConfiguration.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link AndroidDebugBridgeConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeConfiguration { + /** + * The IP address to use for connecting to the Android device. + */ + public String ip = ""; + /** + * Sample configuration parameter. Replace with your own. + */ + public int port; + /** + * Time for scheduled state check. + */ + public int refreshTime = 30; + /** + * Command timeout seconds. + */ + public int timeout = 5; + /** + * Configure media state detection behavior by package + */ + public @Nullable String mediaStateJSONConfig; +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDevice.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDevice.java new file mode 100644 index 0000000000000..8778fcfbbec42 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDevice.java @@ -0,0 +1,349 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import java.io.*; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.OpenHAB; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.tananaev.adblib.AdbBase64; +import com.tananaev.adblib.AdbConnection; +import com.tananaev.adblib.AdbCrypto; +import com.tananaev.adblib.AdbStream; + +/** + * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeDevice { + public static final int ANDROID_MEDIA_STREAM = 3; + private static final String ADB_FOLDER = OpenHAB.getUserDataFolder() + File.separator + ".adb"; + private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class); + private static final Pattern VOLUME_PATTERN = Pattern + .compile("volume is (?\\d.*) in range \\[(?\\d.*)\\.\\.(?\\d.*)]"); + + private static @Nullable AdbCrypto adbCrypto; + + static { + var logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class); + try { + File directory = new File(ADB_FOLDER); + if (!directory.exists()) { + directory.mkdir(); + } + adbCrypto = loadKeyPair(ADB_FOLDER + File.separator + "adb_pub.key", + ADB_FOLDER + File.separator + "adb.key"); + } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException e) { + logger.warn("Unable to setup adb keys: {}", e.getMessage()); + } + } + + private final ScheduledExecutorService scheduler; + + private String ip = "127.0.0.1"; + private int port = 5555; + private int timeoutSec = 5; + private @Nullable Socket socket; + private @Nullable AdbConnection connection; + private @Nullable Future commandFuture; + + AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + } + + public void configure(String ip, int port, int timeout) { + this.ip = ip; + this.port = port; + this.timeoutSec = timeout; + } + + public void sendKeyEvent(String eventCode) + throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException { + runAdbShell("input", "keyevent", eventCode); + } + + public void sendText(String text) + throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException { + runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8)); + } + + public void startPackage(String packageName) + throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException { + var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1"); + if (out.contains("monkey aborted")) + throw new AndroidDebugBridgeDeviceException("Unable to open package"); + } + + public void stopPackage(String packageName) + throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException { + runAdbShell("am", "force-stop", packageName); + } + + public String getCurrentPackage() throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + var out = runAdbShell("dumpsys", "window", "windows", "|", "grep", "mFocusedApp"); + var targetLine = Arrays.stream(out.split("\n")).findFirst().orElse(""); + var lineParts = targetLine.split(" "); + if (lineParts.length >= 2) { + var packageActivityName = lineParts[lineParts.length - 2]; + if (packageActivityName.contains("/")) + return packageActivityName.split("/")[0]; + } + throw new AndroidDebugBridgeDeviceReadException("can read package name"); + } + + public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'"); + if (devicesResp.contains("=")) { + try { + return devicesResp.split("=")[1].equals("ON"); + } catch (NumberFormatException e) { + logger.debug("Unable to parse device wake lock: {}", e.getMessage()); + } + } + throw new AndroidDebugBridgeDeviceReadException("can read screen state"); + } + + public boolean isPlayingMedia(String currentApp) + throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException { + String devicesResp = runAdbShell("dumpsys", "media_session", "|", "grep", "-A", "100", "'Sessions Stack'", "|", + "grep", "-A", "50", currentApp); + String[] mediaSessions = devicesResp.split("\n\n"); + if (mediaSessions.length == 0) { + // no media session found for current app + return false; + } + boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3"); + logger.debug("device media state playing {}", isPlaying); + return isPlaying; + } + + public boolean isPlayingAudio() + throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException { + String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:"); + return audioDump.contains("state:started"); + } + + public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + return getVolume(ANDROID_MEDIA_STREAM); + } + + public void setMediaVolume(int volume) + throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException { + setVolume(ANDROID_MEDIA_STREAM, volume); + } + + public int getPowerWakeLock() throws InterruptedException, AndroidDebugBridgeDeviceException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + String lockResp = runAdbShell("dumpsys", "power", "|", "grep", "Locks", "|", "grep", "'size='"); + if (lockResp.contains("=")) { + try { + return Integer.parseInt(lockResp.replace("\n", "").split("=")[1]); + } catch (NumberFormatException e) { + logger.debug("Unable to parse device wake lock: {}", e.getMessage()); + } + } + throw new AndroidDebugBridgeDeviceReadException("can read wake lock"); + } + + private void setVolume(int stream, int volume) + throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException { + runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--set", String.valueOf(volume)); + } + + public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + return getDeviceProp("ro.product.model"); + } + + public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + return getDeviceProp("ro.build.version.release"); + } + + public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + return getDeviceProp("ro.product.brand"); + } + + public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + return getDeviceProp("ro.serialno"); + } + + private String getDeviceProp(String name) throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + var propValue = runAdbShell("getprop", name, "&&", "sleep", "0.3").replace("\n", "").replace("\r", ""); + if (propValue.length() == 0) { + throw new AndroidDebugBridgeDeviceReadException("Unable to get device property"); + } + return propValue; + } + + private VolumeInfo getVolume(int stream) throws AndroidDebugBridgeDeviceException, InterruptedException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + String volumeResp = runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--get", "|", + "grep", "volume"); + Matcher matcher = VOLUME_PATTERN.matcher(volumeResp); + if (!matcher.find()) + throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info"); + var volumeInfo = new VolumeInfo(Integer.parseInt(matcher.group("current")), + Integer.parseInt(matcher.group("min")), Integer.parseInt(matcher.group("max"))); + logger.debug("Device {}:{} VolumeInfo: current {}, min {}, max {}", this.ip, this.port, volumeInfo.current, + volumeInfo.min, volumeInfo.max); + return volumeInfo; + } + + public boolean isConnected() { + var currentSocket = socket; + return currentSocket != null && currentSocket.isConnected(); + } + + public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException { + this.disconnect(); + AdbConnection adbConnection; + Socket sock; + AdbCrypto crypto = adbCrypto; + if (crypto == null) { + throw new AndroidDebugBridgeDeviceException("Device not connected"); + } + try { + sock = new Socket(); + socket = sock; + sock.connect(new InetSocketAddress(ip, port), (int) TimeUnit.SECONDS.toMillis(15)); + } catch (IOException e) { + logger.debug("Error connecting to {}: [{}] {}", ip, e.getClass().getName(), e.getMessage()); + if (e.getMessage().equals("Socket closed")) { + // Connection aborted by us + throw new InterruptedException(); + } + throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port); + } + try { + adbConnection = AdbConnection.create(sock, crypto); + connection = adbConnection; + adbConnection.connect(15, TimeUnit.SECONDS, false); + } catch (IOException e) { + logger.debug("Error connecting to {}: {}", ip, e.getMessage()); + throw new AndroidDebugBridgeDeviceException("Can not open adb connection " + ip + ":" + port); + } + } + + private String runAdbShell(String... args) + throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException { + var adb = connection; + if (adb == null) { + throw new AndroidDebugBridgeDeviceException("Device not connected"); + } + var commandFuture = scheduler.submit(() -> { + var byteArrayOutputStream = new ByteArrayOutputStream(); + String cmd = String.join(" ", args); + logger.debug("{} - shell:{}", ip, cmd); + try { + AdbStream stream = adb.open("shell:" + cmd); + do { + byteArrayOutputStream.writeBytes(stream.read()); + } while (!stream.isClosed()); + } catch (IOException e) { + String message = e.getMessage(); + if (message != null && !message.equals("Stream closed")) { + throw e; + } + } + return byteArrayOutputStream.toString(StandardCharsets.US_ASCII); + }); + this.commandFuture = commandFuture; + return commandFuture.get(timeoutSec, TimeUnit.SECONDS); + } + + private static AdbBase64 getBase64Impl() { + Charset asciiCharset = Charset.forName("ASCII"); + return bytes -> new String(Base64.getEncoder().encode(bytes), asciiCharset); + } + + private static AdbCrypto loadKeyPair(String pubKeyFile, String privKeyFile) + throws NoSuchAlgorithmException, IOException, InvalidKeySpecException { + File pub = new File(pubKeyFile); + File priv = new File(privKeyFile); + AdbCrypto c = null; + // load key pair + if (pub.exists() && priv.exists()) { + try { + c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), priv, pub); + } catch (IOException ignored) { + // Keys don't exits + } + } + if (c == null) { + // generate key pair + c = AdbCrypto.generateAdbKeyPair(getBase64Impl()); + c.saveAdbKeyPair(priv, pub); + } + return c; + } + + public void disconnect() { + var commandFuture = this.commandFuture; + if (commandFuture != null && !commandFuture.isDone()) { + commandFuture.cancel(true); + } + var adb = connection; + var sock = socket; + if (adb != null) { + try { + adb.close(); + } catch (IOException ignored) { + } + connection = null; + } + if (sock != null) { + try { + sock.close(); + } catch (IOException ignored) { + } + socket = null; + } + } + + public static class VolumeInfo { + public int current; + public int min; + public int max; + + VolumeInfo(int current, int min, int max) { + this.current = current; + this.min = min; + this.max = max; + } + } +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceException.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceException.java new file mode 100644 index 0000000000000..08abf5f5ffc1b --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceException.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AndroidDebugBridgeDiscoveryService} discover Android ADB Instances in the network. + * + * @author Miguel Alvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeDeviceException extends Exception { + private static final long serialVersionUID = 6608406239134276286L; + + public AndroidDebugBridgeDeviceException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceReadException.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceReadException.java new file mode 100644 index 0000000000000..45e868684a5d5 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDeviceReadException.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AndroidDebugBridgeDiscoveryService} discover Android ADB Instances in the network. + * + * @author Miguel Alvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeDeviceReadException extends Exception { + private static final long serialVersionUID = 6608406239134276287L; + + public AndroidDebugBridgeDeviceReadException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDiscoveryService.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDiscoveryService.java new file mode 100644 index 0000000000000..d5aad994bb46a --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeDiscoveryService.java @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import static org.openhab.binding.androiddebugbridge.internal.AndroidDebugBridgeBindingConstants.*; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.cm.Configuration; +import org.osgi.service.cm.ConfigurationAdmin; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AndroidDebugBridgeDiscoveryService} discover Android ADB Instances in the network. + * + * @author Miguel Alvarez - Initial contribution + */ +@NonNullByDefault +@Component(service = DiscoveryService.class, configurationPid = "discovery.androiddebugbridge") +public class AndroidDebugBridgeDiscoveryService extends AbstractDiscoveryService { + static final int TIMEOUT_MS = 60000; + private static final long DISCOVERY_RESULT_TTL_SEC = 300; + public static final String LOCAL_INTERFACE_IP = "127.0.0.1"; + public static final int MAX_RETRIES = 2; + private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDiscoveryService.class); + private final ConfigurationAdmin admin; + private boolean discoveryRunning = false; + + @Activate + public AndroidDebugBridgeDiscoveryService(@Reference ConfigurationAdmin admin) { + super(SUPPORTED_THING_TYPES, TIMEOUT_MS, false); + this.admin = admin; + } + + @Override + protected void startScan() { + logger.debug("scan started: searching android devices"); + discoveryRunning = true; + Enumeration nets; + AndroidDebugBridgeBindingConfiguration configuration = getConfig(); + if (configuration == null) { + return; + } + try { + nets = NetworkInterface.getNetworkInterfaces(); + for (NetworkInterface netint : Collections.list(nets)) { + Enumeration inetAddresses = netint.getInetAddresses(); + for (InetAddress inetAddress : Collections.list(inetAddresses)) { + if (!discoveryRunning) { + break; + } + if (!(inetAddress instanceof Inet4Address) + || inetAddress.getHostAddress().equals(LOCAL_INTERFACE_IP)) { + continue; + } + String[] ipParts = inetAddress.getHostAddress().split("\\."); + for (int i = configuration.discoveryIpRangeMin; i <= configuration.discoveryIpRangeMax; i++) { + if (!discoveryRunning) { + break; + } + ipParts[3] = Integer.toString(i); + String currentIp = String.join(".", ipParts); + try { + var currentAddress = InetAddress.getByName(currentIp); + logger.debug("address: {}", currentIp); + if (currentAddress.isReachable(configuration.discoveryReachableMs)) { + logger.debug("Reachable ip: {}", currentIp); + int retries = 0; + while (retries < MAX_RETRIES) { + try { + discoverWithADB(currentIp, configuration.discoveryPort); + } catch (AndroidDebugBridgeDeviceReadException | TimeoutException e) { + retries++; + if (retries < MAX_RETRIES) { + logger.debug("retrying - pending {}", MAX_RETRIES - retries); + continue; + } + throw e; + } + break; + } + } + } catch (IOException | AndroidDebugBridgeDeviceException | AndroidDebugBridgeDeviceReadException + | TimeoutException | ExecutionException e) { + logger.debug("Error connecting to device at {}: {}", currentIp, e.getMessage()); + } + } + } + } + } catch (SocketException | InterruptedException e) { + logger.warn("Error while discovering: {}", e.getMessage()); + } + } + + private void discoverWithADB(String ip, int port) throws InterruptedException, AndroidDebugBridgeDeviceException, + AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException { + var device = new AndroidDebugBridgeDevice(scheduler); + device.configure(ip, port, 10); + try { + device.connect(); + logger.debug("connected adb at {}:{}", ip, port); + String serialNo = device.getSerialNo(); + String model = device.getModel(); + String androidVersion = device.getAndroidVersion(); + String brand = device.getBrand(); + logger.debug("discovered: {} - {} - {} - {}", model, serialNo, androidVersion, brand); + onDiscoverResult(serialNo, ip, port, model, androidVersion, brand); + } finally { + device.disconnect(); + } + } + + @Override + protected void stopScan() { + super.stopScan(); + discoveryRunning = false; + logger.debug("scan stopped"); + } + + private void onDiscoverResult(String serialNo, String ip, int port, String model, String androidVersion, + String brand) { + Map properties = new HashMap<>(); + properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNo); + properties.put(PARAMETER_IP, ip); + properties.put(PARAMETER_PORT, port); + properties.put(Thing.PROPERTY_MODEL_ID, model); + properties.put(Thing.PROPERTY_VENDOR, brand); + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, androidVersion); + thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_ANDROID_DEVICE, serialNo)) + .withTTL(DISCOVERY_RESULT_TTL_SEC).withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER) + .withProperties(properties).withLabel(String.format("%s (%s)", model, serialNo)).build()); + } + + private @Nullable AndroidDebugBridgeBindingConfiguration getConfig() { + try { + Configuration configOnline = admin.getConfiguration(BINDING_CONFIGURATION_PID, null); + if (configOnline != null) { + Dictionary props = configOnline.getProperties(); + if (props != null) { + Map propMap = Collections.list(props.keys()).stream() + .collect(Collectors.toMap(Function.identity(), props::get)); + return new org.openhab.core.config.core.Configuration(propMap) + .as(AndroidDebugBridgeBindingConfiguration.class); + } + } + } catch (IOException e) { + logger.warn("Unable to read configuration: {}", e.getMessage()); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandler.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandler.java new file mode 100644 index 0000000000000..245e38b90ca37 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandler.java @@ -0,0 +1,339 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import static org.openhab.binding.androiddebugbridge.internal.AndroidDebugBridgeBindingConstants.*; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.*; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link AndroidDebugBridgeHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +public class AndroidDebugBridgeHandler extends BaseThingHandler { + + public static final String KEY_EVENT_PLAY = "126"; + public static final String KEY_EVENT_PAUSE = "127"; + public static final String KEY_EVENT_NEXT = "87"; + public static final String KEY_EVENT_PREVIOUS = "88"; + public static final String KEY_EVENT_MEDIA_REWIND = "89"; + public static final String KEY_EVENT_MEDIA_FAST_FORWARD = "90"; + private static final Gson GSON = new Gson(); + private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeHandler.class); + private final AndroidDebugBridgeDevice adbConnection; + private int maxMediaVolume = 0; + private AndroidDebugBridgeConfiguration config = new AndroidDebugBridgeConfiguration(); + private @Nullable ScheduledFuture connectionCheckerSchedule; + private AndroidDebugBridgeMediaStatePackageConfig @Nullable [] packageConfigs = null; + + public AndroidDebugBridgeHandler(Thing thing) { + super(thing); + this.adbConnection = new AndroidDebugBridgeDevice(scheduler); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + var currentConfig = config; + if (currentConfig == null) { + return; + } + try { + if (!adbConnection.isConnected()) { + // try reconnect + adbConnection.connect(); + } + handleCommandInternal(channelUID, command); + } catch (InterruptedException ignored) { + } catch (AndroidDebugBridgeDeviceException | ExecutionException e) { + if (!(e.getCause() instanceof InterruptedException)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + adbConnection.disconnect(); + } + } catch (AndroidDebugBridgeDeviceReadException e) { + logger.warn("{} - read error: {}", currentConfig.ip, e.getMessage()); + } catch (TimeoutException e) { + logger.warn("{} - timeout error", currentConfig.ip); + } + } + + private void handleCommandInternal(ChannelUID channelUID, Command command) + throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException, + TimeoutException, ExecutionException { + if (!isLinked(channelUID)) { + return; + } + String channelId = channelUID.getId(); + switch (channelId) { + case KEY_EVENT_CHANNEL: + adbConnection.sendKeyEvent(command.toFullString()); + break; + case TEXT_CHANNEL: + adbConnection.sendText(command.toFullString()); + break; + case MEDIA_VOLUME_CHANNEL: + handleMediaVolume(channelUID, command); + break; + case MEDIA_CONTROL_CHANNEL: + handleMediaControlCommand(channelUID, command); + break; + case START_PACKAGE_CHANNEL: + adbConnection.startPackage(command.toFullString()); + updateState(new ChannelUID(this.thing.getUID(), CURRENT_PACKAGE_CHANNEL), + new StringType(command.toFullString())); + break; + case STOP_PACKAGE_CHANNEL: + adbConnection.stopPackage(command.toFullString()); + break; + case STOP_CURRENT_PACKAGE_CHANNEL: + if (OnOffType.from(command.toFullString()).equals(OnOffType.OFF)) { + adbConnection.stopPackage(adbConnection.getCurrentPackage()); + } + break; + case CURRENT_PACKAGE_CHANNEL: + if (command instanceof RefreshType) { + var packageName = adbConnection.getCurrentPackage(); + updateState(channelUID, new StringType(packageName)); + } + break; + case WAKE_LOCK_CHANNEL: + if (command instanceof RefreshType) { + int lock = adbConnection.getPowerWakeLock(); + updateState(channelUID, new DecimalType(lock)); + } + break; + case SCREEN_STATE_CHANNEL: + if (command instanceof RefreshType) { + boolean screenState = adbConnection.isScreenOn(); + updateState(channelUID, OnOffType.from(screenState)); + } + break; + } + } + + private void handleMediaVolume(ChannelUID channelUID, Command command) + throws InterruptedException, AndroidDebugBridgeDeviceReadException, AndroidDebugBridgeDeviceException, + TimeoutException, ExecutionException { + if (command instanceof RefreshType) { + var volumeInfo = adbConnection.getMediaVolume(); + maxMediaVolume = volumeInfo.max; + updateState(channelUID, new PercentType((int) Math.round(toPercent(volumeInfo.current, volumeInfo.max)))); + } else { + if (maxMediaVolume == 0) { + return; // We can not transform percentage + } + int targetVolume = Integer.parseInt(command.toFullString()); + adbConnection.setMediaVolume((int) Math.round(fromPercent(targetVolume, maxMediaVolume))); + updateState(channelUID, new PercentType(targetVolume)); + } + } + + private double toPercent(double value, double maxValue) { + return (value / maxValue) * 100; + } + + private double fromPercent(double value, double maxValue) { + return (value / 100) * maxValue; + } + + private void handleMediaControlCommand(ChannelUID channelUID, Command command) + throws InterruptedException, AndroidDebugBridgeDeviceException, AndroidDebugBridgeDeviceReadException, + TimeoutException, ExecutionException { + if (command instanceof RefreshType) { + boolean playing; + String currentPackage = adbConnection.getCurrentPackage(); + var currentPackageConfig = packageConfigs != null ? Arrays.stream(packageConfigs) + .filter(pc -> pc.name.equals(currentPackage)).findFirst().orElse(null) : null; + if (currentPackageConfig != null) { + logger.debug("media stream config found for {}, mode: {}", currentPackage, currentPackageConfig.mode); + switch (currentPackageConfig.mode) { + case "idle": + playing = false; + break; + case "wake_lock": + int wakeLockState = adbConnection.getPowerWakeLock(); + playing = currentPackageConfig.wakeLockPlayStates.contains(wakeLockState); + break; + case "media_state": + playing = adbConnection.isPlayingMedia(currentPackage); + break; + case "audio": + playing = adbConnection.isPlayingAudio(); + break; + default: + logger.warn("media state config: package {} unsupported mode", currentPackage); + playing = false; + } + } else { + logger.debug("media stream config not found for {}", currentPackage); + playing = adbConnection.isPlayingMedia(currentPackage); + } + updateState(channelUID, playing ? PlayPauseType.PLAY : PlayPauseType.PAUSE); + } else if (command instanceof PlayPauseType) { + if (command == PlayPauseType.PLAY) { + adbConnection.sendKeyEvent(KEY_EVENT_PLAY); + updateState(channelUID, PlayPauseType.PLAY); + } else if (command == PlayPauseType.PAUSE) { + adbConnection.sendKeyEvent(KEY_EVENT_PAUSE); + updateState(channelUID, PlayPauseType.PAUSE); + } + } else if (command instanceof NextPreviousType) { + if (command == NextPreviousType.NEXT) { + adbConnection.sendKeyEvent(KEY_EVENT_NEXT); + } else if (command == NextPreviousType.PREVIOUS) { + adbConnection.sendKeyEvent(KEY_EVENT_PREVIOUS); + } + } else if (command instanceof RewindFastforwardType) { + if (command == RewindFastforwardType.FASTFORWARD) { + adbConnection.sendKeyEvent(KEY_EVENT_MEDIA_FAST_FORWARD); + } else if (command == RewindFastforwardType.REWIND) { + adbConnection.sendKeyEvent(KEY_EVENT_MEDIA_REWIND); + } + } else { + logger.warn("Unknown media control command: {}", command); + } + } + + @Override + public void initialize() { + var currentConfig = getConfigAs(AndroidDebugBridgeConfiguration.class); + config = currentConfig; + var mediaStateJSONConfig = currentConfig.mediaStateJSONConfig; + if (mediaStateJSONConfig != null && !mediaStateJSONConfig.isEmpty()) { + loadMediaStateConfig(mediaStateJSONConfig); + } + adbConnection.configure(currentConfig.ip, currentConfig.port, currentConfig.timeout); + updateStatus(ThingStatus.UNKNOWN); + connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(this::checkConnection, 0, + currentConfig.refreshTime, TimeUnit.SECONDS); + } + + private void loadMediaStateConfig(String mediaStateJSONConfig) { + try { + this.packageConfigs = GSON.fromJson(mediaStateJSONConfig, + AndroidDebugBridgeMediaStatePackageConfig[].class); + } catch (JsonSyntaxException e) { + logger.warn("unable to parse media state config: {}", e.getMessage()); + } + } + + @Override + public void dispose() { + var schedule = connectionCheckerSchedule; + if (schedule != null) { + schedule.cancel(true); + connectionCheckerSchedule = null; + } + packageConfigs = null; + adbConnection.disconnect(); + super.dispose(); + } + + public void checkConnection() { + var currentConfig = config; + if (currentConfig == null) + return; + try { + logger.debug("Refresh device {} status", currentConfig.ip); + if (adbConnection.isConnected()) { + updateStatus(ThingStatus.ONLINE); + refreshStatus(); + } else { + try { + adbConnection.connect(); + } catch (AndroidDebugBridgeDeviceException e) { + logger.debug("Error connecting to device; [{}]: {}", e.getClass().getCanonicalName(), + e.getMessage()); + updateStatus(ThingStatus.OFFLINE); + return; + } + if (adbConnection.isConnected()) { + updateStatus(ThingStatus.ONLINE); + refreshStatus(); + } + } + } catch (InterruptedException ignored) { + } catch (AndroidDebugBridgeDeviceException | ExecutionException e) { + logger.debug("Connection checker error: {}", e.getMessage()); + adbConnection.disconnect(); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } + + private void refreshStatus() throws InterruptedException, AndroidDebugBridgeDeviceException, ExecutionException { + try { + handleCommandInternal(new ChannelUID(this.thing.getUID(), MEDIA_VOLUME_CHANNEL), RefreshType.REFRESH); + } catch (AndroidDebugBridgeDeviceReadException e) { + logger.warn("Unable to refresh media volume: {}", e.getMessage()); + } catch (TimeoutException e) { + logger.warn("Unable to refresh media volume: Timeout"); + } + try { + handleCommandInternal(new ChannelUID(this.thing.getUID(), MEDIA_CONTROL_CHANNEL), RefreshType.REFRESH); + } catch (AndroidDebugBridgeDeviceReadException e) { + logger.warn("Unable to refresh play status: {}", e.getMessage()); + } catch (TimeoutException e) { + logger.warn("Unable to refresh play status: Timeout"); + } + try { + handleCommandInternal(new ChannelUID(this.thing.getUID(), CURRENT_PACKAGE_CHANNEL), RefreshType.REFRESH); + } catch (AndroidDebugBridgeDeviceReadException e) { + logger.warn("Unable to refresh current package: {}", e.getMessage()); + } catch (TimeoutException e) { + logger.warn("Unable to refresh current package: Timeout"); + } + try { + handleCommandInternal(new ChannelUID(this.thing.getUID(), WAKE_LOCK_CHANNEL), RefreshType.REFRESH); + } catch (AndroidDebugBridgeDeviceReadException e) { + logger.warn("Unable to refresh wake lock: {}", e.getMessage()); + } catch (TimeoutException e) { + logger.warn("Unable to refresh wake lock: Timeout"); + } + try { + handleCommandInternal(new ChannelUID(this.thing.getUID(), SCREEN_STATE_CHANNEL), RefreshType.REFRESH); + } catch (AndroidDebugBridgeDeviceReadException e) { + logger.warn("Unable to refresh screen state: {}", e.getMessage()); + } catch (TimeoutException e) { + logger.warn("Unable to refresh screen state: Timeout"); + } + } + + static class AndroidDebugBridgeMediaStatePackageConfig { + public String name = ""; + public String mode = ""; + public List wakeLockPlayStates = List.of(); + } +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandlerFactory.java b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandlerFactory.java new file mode 100644 index 0000000000000..761ab5a4b948d --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/java/org/openhab/binding/androiddebugbridge/internal/AndroidDebugBridgeHandlerFactory.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.androiddebugbridge.internal; + +import static org.openhab.binding.androiddebugbridge.internal.AndroidDebugBridgeBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Component; + +/** + * The {@link AndroidDebugBridgeHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Miguel Álvarez - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = BINDING_CONFIGURATION_PID, service = ThingHandlerFactory.class) +public class AndroidDebugBridgeHandlerFactory extends BaseThingHandlerFactory { + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + if (THING_TYPE_ANDROID_DEVICE.equals(thingTypeUID)) { + return new AndroidDebugBridgeHandler(thing); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 0000000000000..4f0b9131037d8 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,31 @@ + + + + Android Debug Bridge Binding + This is the binding for connect to Android devices using the Android Debug Bridge protocol. + + + + + Port used on discovery to connect to the device through adb. + 5555 + + + + Milliseconds to wait while discovering to determine if the ip is reachable. + 3000 + + + + Used to limit the numbers of ips checked while discovering. + 0 + + + + Used to limit the numbers of ips checked while discovering. + 255 + + + diff --git a/bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..98710f5459c74 --- /dev/null +++ b/bundles/org.openhab.binding.androiddebugbridge/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,396 @@ + + + + + + Android Device Thing for Android Debug Bridge Binding + + + + + + + + + + + + + serial + + + network-address + + Device ip address. + + + + Device port listening to adb connections. + 5555 + + + + Seconds between device status refreshes. + 30 + + + + Command timeout seconds. + 5 + + + + JSON config that allows to modify the media state detection strategy for each app. Refer to the binding + documentation. + + + + + + String + + Send key event to android device + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + String + + Send text to android device + + + + String + + Run application by package name + + + + String + + Stop application by package name + + + + Switch + + Stops the top application in screen when receives an OFF command + + + + String + + Package name of the top application in screen + + + + + Number + + Power Wake Lock State + + + + + Switch + + Screen Power State + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 6df37e99c4b45..087005fe3c82d 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -47,6 +47,7 @@ org.openhab.binding.amazondashbutton org.openhab.binding.amazonechocontrol org.openhab.binding.ambientweather + org.openhab.binding.androiddebugbridge org.openhab.binding.astro org.openhab.binding.atlona org.openhab.binding.autelis