diff --git a/CODEOWNERS b/CODEOWNERS index c2270388ba127..4d99c2fe6f2c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -24,6 +24,7 @@ /bundles/org.openhab.binding.ambientweather/ @mhilbush /bundles/org.openhab.binding.amplipi/ @kaikreuzer /bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD +/bundles/org.openhab.binding.androidtv/ @morph166955 /bundles/org.openhab.binding.anel/ @paphko /bundles/org.openhab.binding.astro/ @gerrieg /bundles/org.openhab.binding.atlona/ @tmrobert8 @mlobstein diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d968a3512444d..24bc9b8ade01b 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -111,6 +111,11 @@ org.openhab.binding.androiddebugbridge ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.androidtv + ${project.version} + org.openhab.addons.bundles org.openhab.binding.anel diff --git a/bundles/org.openhab.binding.androidtv/NOTICE b/bundles/org.openhab.binding.androidtv/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/NOTICE @@ -0,0 +1,13 @@ +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 diff --git a/bundles/org.openhab.binding.androidtv/README.md b/bundles/org.openhab.binding.androidtv/README.md new file mode 100644 index 0000000000000..b3132569f7bf9 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/README.md @@ -0,0 +1,484 @@ +# AndroidTV Binding + +This binding is designed to emulate different protocols to interact with the AndroidTV platform. +Currently it emulates both the Google Video App to interact with a variety of AndroidTVs for purposes of remote control. +It also currently emulates the Nvidia ShieldTV Android App to interact with an Nvidia ShieldTV for purposes of remote control. + +## Supported Things + +This binding supports two thing types: + +- **googletv** - An AndroidTV running Google Video +- **shieldtv** - An Nvidia ShieldTV + +## Discovery + +Both GoogleTVs and ShieldTVs should be added automatically to the inbox through the mDNS discovery process. + +In the case of the ShieldTV, openHAB will likely create an inbox entry for both a GoogleTV and a ShieldTV device. +Only the ShieldTV device should be configured, the GoogleTV can be ignored. +There is no benefit to configuring two things for a ShieldTV device. +This could cause undesired effects. + +## Binding Configuration + +This binding does not require any special configuration files. + +This binding does require a PIN login process (documented below) upon first connection. + +This binding requires GoogleTV to be installed on the device (https://play.google.com/store/apps/details?id=com.google.android.videos) + +## Thing Configuration + +There are three required fields to connect successfully to a ShieldTV. + +| Name | Type | Description | Default | Required | Advanced | +|------------------|---------|---------------------------------------|---------|----------|----------| +| ipAddress | text | IP address of the device | N/A | yes | no | +| keystore | text | Location of the Java Keystore | N/A | no | no | +| keystorePassword | text | Password of the Java Keystore | N/A | no | no | + +```java +Thing androidtv:shieldtv:livingroom [ ipAddress="192.168.1.2" ] +Thing androidtv:googletv:theater [ ipAddress="192.168.1.3" ] +``` + +## Channels + +| Channel | Type | Description | GoogleTV | ShieldTV | +|------------|--------|-----------------------------|----------|----------| +| keyboard | String | Keyboard Data Entry | RW | RW | +| keypress | String | Manual Key Press Entry | RW | RW | +| keycode | String | Direct KEYCODE Entry | RW | RW | +| pincode | String | PIN Code Entry | RW | RW | +| app | String | App Control | RO | RW | +| appname | String | App Name | N/A | RW | +| appurl | String | App URL | N/A | RW | +| player | Player | Player Control | RW | RW | +| power | Switch | Power Control | RW | RW | +| volume | Dimmer | Volume Control | RO | RO | +| mute | Switch | Mute Control | RW | RW | + + +```java +String ShieldTV_KEYBOARD "KEYBOARD [%s]" { channel = "androidtv:shieldtv:livingroom:keyboard" } +String ShieldTV_KEYPRESS "KEYPRESS [%s]" { channel = "androidtv:shieldtv:livingroom:keypress" } +String ShieldTV_KEYCODE "KEYCODE [%s]" { channel = "androidtv:shieldtv:livingroom:keycode" } +String ShieldTV_PINCODE "PINCODE [%s]" { channel = "androidtv:shieldtv:livingroom:pincode" } +String ShieldTV_APP "APP [%s]" { channel = "androidtv:shieldtv:livingroom:app" } +String ShieldTV_APPNAME "APPNAME [%s]" { channel = "androidtv:shieldtv:livingroom:appname" } +String ShieldTV_APPURL "APPURL [%s]" { channel = "androidtv:shieldtv:livingroom:appurl" } +Player ShieldTV_PLAYER "PLAYER [%s]" { channel = "androidtv:shieldtv:livingroom:player" } +Switch ShieldTV_POWER "POWER [%s]" { channel = "androidtv:shieldtv:livingroom:power" } +Dimmer ShieldTV_VOLUME "VOLUME [%s]" { channel = "androidtv:shieldtv:livingroom:volume" } +Switch ShieldTV_MUTE "MUTE [%s]" { channel = "androidtv:shieldtv:livingroom:mute" } + +String GoogleTV_KEYBOARD "KEYBOARD [%s]" { channel = "androidtv:googletv:theater:keyboard" } +String GoogleTV_KEYPRESS "KEYPRESS [%s]" { channel = "androidtv:googletv:theater:keypress" } +String GoogleTV_KEYCODE "KEYCODE [%s]" { channel = "androidtv:googletv:theater:keycode" } +String GoogleTV_PINCODE "PINCODE [%s]" { channel = "androidtv:googletv:theater:pincode" } +String GoogleTV_APP "APP [%s]" { channel = "androidtv:googletv:theater:app" } +Player GoogleTV_PLAYER "PLAYER [%s]" { channel = "androidtv:googletv:theater:player" } +Switch GoogleTV_POWER "POWER [%s]" { channel = "androidtv:googletv:theater:power" } +Dimmer GoogleTV_VOLUME "VOLUME [%s]" { channel = "androidtv:googletv:theater:volume" } +Switch GoogleTV_MUTE "MUTE [%s]" { channel = "androidtv:googletv:theater:mute" } +``` + +KEYPRESS will accept the following commands as strings (case sensitive): + +- KEY_UP +- KEY_DOWN +- KEY_RIGHT +- KEY_LEFT +- KEY_ENTER +- KEY_HOME +- KEY_BACK +- KEY_MENU +- KEY_PLAY +- KEY_PAUSE +- KEY_PLAYPAUSE +- KEY_STOP +- KEY_NEXT +- KEY_PREVIOUS +- KEY_REWIND +- KEY_FORWARD +- KEY_POWER +- KEY_GOOGLE +- KEY_VOLUP +- KEY_VOLDOWN +- KEY_MUTE +- KEY_SUBMIT + +The list above causes an instantanious "press and release" of each button. +If you would like to manually control the press and release of each you may append _PRESS and _RELEASE to the end of each. +(e.g. KEY_FORWARD_PRESS or KEY_FORWARD_RELEASE) + +You may also send an ASCII character as a single letter to simulate a key entry (e.g KEY_A, KEY_1, KEY_z). +Use KEY_SUBMIT when full text entry is complete to tell the shield to process the line. +KEY_SUBMIT is automatically sent by KEYBOARD when a command is sent to the channel. + +APP will display the currently active app as presented by the AndroidTV. +You may also send it a command of the app package name (e.g. com.google.android.youtube.tv) to start/change-to that app. + +KEYCODE values are listed at the bottom of this README. +NOTE: Not all KEYCODES work on all devices. Keycodes above 255 have not been tested. + +## Pin Code Process + +For the AndroidTV to be successfully accessed an on-screen PIN authentication is required on the first connection. + +To begin the PIN process, send the text "REQUEST" to the pincode channel while watching your AndroidTV. + +A 6 digit PIN should be displayed on the screen. + +To complete the PIN process, send the PIN displayed to the pincode channel. + +The display should return back to where it was originally. + +If you are on a ShieldTV you must run that process a second time to authenticate the GoogleTV protocol stack. + +This completes the PIN process. + +Upon reconnection (either from reconfiguration or a restart of OpenHAB), you should now see a message of "Login Successful" in openhab.log + +## Full Example + +```java +Thing androidtv:shieldtv:livingroom [ ipAddress="192.168.1.2" ] +Thing androidtv:googletv:theater [ ipAddress="192.168.1.3" ] +``` + +```java +String ShieldTV_KEYBOARD "KEYBOARD [%s]" { channel = "androidtv:shieldtv:livingroom:keyboard" } +String ShieldTV_KEYPRESS "KEYPRESS [%s]" { channel = "androidtv:shieldtv:livingroom:keypress" } +String ShieldTV_KEYCODE "KEYCODE [%s]" { channel = "androidtv:shieldtv:livingroom:keycode" } +String ShieldTV_PINCODE "PINCODE [%s]" { channel = "androidtv:shieldtv:livingroom:pincode" } +String ShieldTV_APP "APP [%s]" { channel = "androidtv:shieldtv:livingroom:app" } +String ShieldTV_APPNAME "APPNAME [%s]" { channel = "androidtv:shieldtv:livingroom:appname" } +String ShieldTV_APPURL "APPURL [%s]" { channel = "androidtv:shieldtv:livingroom:appurl" } +Player ShieldTV_PLAYER "PLAYER [%s]" { channel = "androidtv:shieldtv:livingroom:player" } +Switch ShieldTV_POWER "POWER [%s]" { channel = "androidtv:shieldtv:livingroom:power" } +Dimmer ShieldTV_VOLUME "VOLUME [%s]" { channel = "androidtv:shieldtv:livingroom:volume" } +Switch ShieldTV_MUTE "MUTE [%s]" { channel = "androidtv:shieldtv:livingroom:mute" } + +String GoogleTV_KEYBOARD "KEYBOARD [%s]" { channel = "androidtv:googletv:theater:keyboard" } +String GoogleTV_KEYPRESS "KEYPRESS [%s]" { channel = "androidtv:googletv:theater:keypress" } +String GoogleTV_KEYCODE "KEYCODE [%s]" { channel = "androidtv:googletv:theater:keycode" } +String GoogleTV_PINCODE "PINCODE [%s]" { channel = "androidtv:googletv:theater:pincode" } +String GoogleTV_APP "APP [%s]" { channel = "androidtv:googletv:theater:app" } +Player GoogleTV_PLAYER "PLAYER [%s]" { channel = "androidtv:googletv:theater:player" } +Switch GoogleTV_POWER "POWER [%s]" { channel = "androidtv:googletv:theater:power" } +Dimmer GoogleTV_VOLUME "VOLUME [%s]" { channel = "androidtv:googletv:theater:volume" } +Switch GoogleTV_MUTE "MUTE [%s]" { channel = "androidtv:googletv:theater:mute" } +``` + +## Google Keycodes + +| CODE | BUTTON | +|------|--------| +| 0 | KEYCODE_UNKNOWN | +| 1 | KEYCODE_SOFT_LEFT | +| 2 | KEYCODE_SOFT_RIGHT | +| 3 | KEYCODE_HOME | +| 4 | KEYCODE_BACK | +| 5 | KEYCODE_CALL | +| 6 | KEYCODE_ENDCALL | +| 7 | KEYCODE_0 | +| 8 | KEYCODE_1 | +| 9 | KEYCODE_2 | +| 10 | KEYCODE_3 | +| 11 | KEYCODE_4 | +| 12 | KEYCODE_5 | +| 13 | KEYCODE_6 | +| 14 | KEYCODE_7 | +| 15 | KEYCODE_8 | +| 16 | KEYCODE_9 | +| 17 | KEYCODE_STAR | +| 18 | KEYCODE_POUND | +| 19 | KEYCODE_DPAD_UP | +| 20 | KEYCODE_DPAD_DOWN | +| 21 | KEYCODE_DPAD_LEFT | +| 22 | KEYCODE_DPAD_RIGHT | +| 23 | KEYCODE_DPAD_CENTER | +| 24 | KEYCODE_VOLUME_UP | +| 25 | KEYCODE_VOLUME_DOWN | +| 26 | KEYCODE_POWER | +| 27 | KEYCODE_CAMERA | +| 28 | KEYCODE_CLEAR | +| 29 | KEYCODE_A | +| 30 | KEYCODE_B | +| 31 | KEYCODE_C | +| 32 | KEYCODE_D | +| 33 | KEYCODE_E | +| 34 | KEYCODE_F | +| 35 | KEYCODE_G | +| 36 | KEYCODE_H | +| 37 | KEYCODE_I | +| 38 | KEYCODE_J | +| 39 | KEYCODE_K | +| 40 | KEYCODE_L | +| 41 | KEYCODE_M | +| 42 | KEYCODE_N | +| 43 | KEYCODE_O | +| 44 | KEYCODE_P | +| 45 | KEYCODE_Q | +| 46 | KEYCODE_R | +| 47 | KEYCODE_S | +| 48 | KEYCODE_T | +| 49 | KEYCODE_U | +| 50 | KEYCODE_V | +| 51 | KEYCODE_W | +| 52 | KEYCODE_X | +| 53 | KEYCODE_Y | +| 54 | KEYCODE_Z | +| 55 | KEYCODE_COMMA | +| 56 | KEYCODE_PERIOD | +| 57 | KEYCODE_ALT_LEFT | +| 58 | KEYCODE_ALT_RIGHT | +| 59 | KEYCODE_SHIFT_LEFT | +| 60 | KEYCODE_SHIFT_RIGHT | +| 61 | KEYCODE_TAB | +| 62 | KEYCODE_SPACE | +| 63 | KEYCODE_SYM | +| 64 | KEYCODE_EXPLORER | +| 65 | KEYCODE_ENVELOPE | +| 66 | KEYCODE_ENTER | +| 67 | KEYCODE_DEL | +| 68 | KEYCODE_GRAVE | +| 69 | KEYCODE_MINUS | +| 70 | KEYCODE_EQUALS | +| 71 | KEYCODE_LEFT_BRACKET | +| 72 | KEYCODE_RIGHT_BRACKET | +| 73 | KEYCODE_BACKSLASH | +| 74 | KEYCODE_SEMICOLON | +| 75 | KEYCODE_APOSTROPHE | +| 76 | KEYCODE_SLASH | +| 77 | KEYCODE_AT | +| 78 | KEYCODE_NUM | +| 79 | KEYCODE_HEADSETHOOK | +| 80 | KEYCODE_FOCUS | +| 81 | KEYCODE_PLUS | +| 82 | KEYCODE_MENU | +| 83 | KEYCODE_NOTIFICATION | +| 84 | KEYCODE_SEARCH | +| 85 | KEYCODE_MEDIA_PLAY_PAUSE | +| 86 | KEYCODE_MEDIA_STOP | +| 87 | KEYCODE_MEDIA_NEXT | +| 88 | KEYCODE_MEDIA_PREVIOUS | +| 89 | KEYCODE_MEDIA_REWIND | +| 90 | KEYCODE_MEDIA_FAST_FORWARD | +| 91 | KEYCODE_MUTE | +| 92 | KEYCODE_PAGE_UP | +| 93 | KEYCODE_PAGE_DOWN | +| 94 | KEYCODE_PICTSYMBOLS | +| 95 | KEYCODE_SWITCH_CHARSET | +| 96 | KEYCODE_BUTTON_A | +| 97 | KEYCODE_BUTTON_B | +| 98 | KEYCODE_BUTTON_C | +| 99 | KEYCODE_BUTTON_X | +| 100 | KEYCODE_BUTTON_Y | +| 101 | KEYCODE_BUTTON_Z | +| 102 | KEYCODE_BUTTON_L1 | +| 103 | KEYCODE_BUTTON_R1 | +| 104 | KEYCODE_BUTTON_L2 | +| 105 | KEYCODE_BUTTON_R2 | +| 106 | KEYCODE_BUTTON_THUMBL | +| 107 | KEYCODE_BUTTON_THUMBR | +| 108 | KEYCODE_BUTTON_START | +| 109 | KEYCODE_BUTTON_SELECT | +| 110 | KEYCODE_BUTTON_MODE | +| 111 | KEYCODE_ESCAPE | +| 112 | KEYCODE_FORWARD_DEL | +| 113 | KEYCODE_CTRL_LEFT | +| 114 | KEYCODE_CTRL_RIGHT | +| 115 | KEYCODE_CAPS_LOCK | +| 116 | KEYCODE_SCROLL_LOCK | +| 117 | KEYCODE_META_LEFT | +| 118 | KEYCODE_META_RIGHT | +| 119 | KEYCODE_FUNCTION | +| 120 | KEYCODE_SYSRQ | +| 121 | KEYCODE_BREAK | +| 122 | KEYCODE_MOVE_HOME | +| 123 | KEYCODE_MOVE_END | +| 124 | KEYCODE_INSERT | +| 125 | KEYCODE_FORWARD | +| 126 | KEYCODE_MEDIA_PLAY | +| 127 | KEYCODE_MEDIA_PAUSE | +| 128 | KEYCODE_MEDIA_CLOSE | +| 129 | KEYCODE_MEDIA_EJECT | +| 130 | KEYCODE_MEDIA_RECORD | +| 131 | KEYCODE_F1 | +| 132 | KEYCODE_F2 | +| 133 | KEYCODE_F3 | +| 134 | KEYCODE_F4 | +| 135 | KEYCODE_F5 | +| 136 | KEYCODE_F6 | +| 137 | KEYCODE_F7 | +| 138 | KEYCODE_F8 | +| 139 | KEYCODE_F9 | +| 140 | KEYCODE_F10 | +| 141 | KEYCODE_F11 | +| 142 | KEYCODE_F12 | +| 143 | KEYCODE_NUM_LOCK | +| 144 | KEYCODE_NUMPAD_0 | +| 145 | KEYCODE_NUMPAD_1 | +| 146 | KEYCODE_NUMPAD_2 | +| 147 | KEYCODE_NUMPAD_3 | +| 148 | KEYCODE_NUMPAD_4 | +| 149 | KEYCODE_NUMPAD_5 | +| 150 | KEYCODE_NUMPAD_6 | +| 151 | KEYCODE_NUMPAD_7 | +| 152 | KEYCODE_NUMPAD_8 | +| 153 | KEYCODE_NUMPAD_9 | +| 154 | KEYCODE_NUMPAD_DIVIDE | +| 155 | KEYCODE_NUMPAD_MULTIPLY | +| 156 | KEYCODE_NUMPAD_SUBTRACT | +| 157 | KEYCODE_NUMPAD_ADD | +| 158 | KEYCODE_NUMPAD_DOT | +| 159 | KEYCODE_NUMPAD_COMMA | +| 160 | KEYCODE_NUMPAD_ENTER | +| 161 | KEYCODE_NUMPAD_EQUALS | +| 162 | KEYCODE_NUMPAD_LEFT_PAREN | +| 163 | KEYCODE_NUMPAD_RIGHT_PAREN | +| 164 | KEYCODE_VOLUME_MUTE | +| 165 | KEYCODE_INFO | +| 166 | KEYCODE_CHANNEL_UP | +| 167 | KEYCODE_CHANNEL_DOWN | +| 168 | KEYCODE_ZOOM_IN | +| 169 | KEYCODE_ZOOM_OUT | +| 170 | KEYCODE_TV | +| 171 | KEYCODE_WINDOW | +| 172 | KEYCODE_GUIDE | +| 173 | KEYCODE_DVR | +| 174 | KEYCODE_BOOKMARK | +| 175 | KEYCODE_CAPTIONS | +| 176 | KEYCODE_SETTINGS | +| 177 | KEYCODE_TV_POWER | +| 178 | KEYCODE_TV_INPUT | +| 179 | KEYCODE_STB_POWER | +| 180 | KEYCODE_STB_INPUT | +| 181 | KEYCODE_AVR_POWER | +| 182 | KEYCODE_AVR_INPUT | +| 183 | KEYCODE_PROG_RED | +| 184 | KEYCODE_PROG_GREEN | +| 185 | KEYCODE_PROG_YELLOW | +| 186 | KEYCODE_PROG_BLUE | +| 187 | KEYCODE_APP_SWITCH | +| 188 | KEYCODE_BUTTON_1 | +| 189 | KEYCODE_BUTTON_2 | +| 190 | KEYCODE_BUTTON_3 | +| 191 | KEYCODE_BUTTON_4 | +| 192 | KEYCODE_BUTTON_5 | +| 193 | KEYCODE_BUTTON_6 | +| 194 | KEYCODE_BUTTON_7 | +| 195 | KEYCODE_BUTTON_8 | +| 196 | KEYCODE_BUTTON_9 | +| 197 | KEYCODE_BUTTON_10 | +| 198 | KEYCODE_BUTTON_11 | +| 199 | KEYCODE_BUTTON_12 | +| 200 | KEYCODE_BUTTON_13 | +| 201 | KEYCODE_BUTTON_14 | +| 202 | KEYCODE_BUTTON_15 | +| 203 | KEYCODE_BUTTON_16 | +| 204 | KEYCODE_LANGUAGE_SWITCH | +| 205 | KEYCODE_MANNER_MODE | +| 206 | KEYCODE_3D_MODE | +| 207 | KEYCODE_CONTACTS | +| 208 | KEYCODE_CALENDAR | +| 209 | KEYCODE_MUSIC | +| 210 | KEYCODE_CALCULATOR | +| 211 | KEYCODE_ZENKAKU_HANKAKU | +| 212 | KEYCODE_EISU | +| 213 | KEYCODE_MUHENKAN | +| 214 | KEYCODE_HENKAN | +| 215 | KEYCODE_KATAKANA_HIRAGANA | +| 216 | KEYCODE_YEN | +| 217 | KEYCODE_RO | +| 218 | KEYCODE_KANA | +| 219 | KEYCODE_ASSIST | +| 220 | KEYCODE_BRIGHTNESS_DOWN | +| 221 | KEYCODE_BRIGHTNESS_UP | +| 222 | KEYCODE_MEDIA_AUDIO_TRACK | +| 223 | KEYCODE_SLEEP | +| 224 | KEYCODE_WAKEUP | +| 225 | KEYCODE_PAIRING | +| 226 | KEYCODE_MEDIA_TOP_MENU | +| 227 | KEYCODE_11 | +| 228 | KEYCODE_12 | +| 229 | KEYCODE_LAST_CHANNEL | +| 230 | KEYCODE_TV_DATA_SERVICE | +| 231 | KEYCODE_VOICE_ASSIST | +| 232 | KEYCODE_TV_RADIO_SERVICE | +| 233 | KEYCODE_TV_TELETEXT | +| 234 | KEYCODE_TV_NUMBER_ENTRY | +| 235 | KEYCODE_TV_TERRESTRIAL_ANALOG | +| 236 | KEYCODE_TV_TERRESTRIAL_DIGITAL | +| 237 | KEYCODE_TV_SATELLITE | +| 238 | KEYCODE_TV_SATELLITE_BS | +| 239 | KEYCODE_TV_SATELLITE_CS | +| 240 | KEYCODE_TV_SATELLITE_SERVICE | +| 241 | KEYCODE_TV_NETWORK | +| 242 | KEYCODE_TV_ANTENNA_CABLE | +| 243 | KEYCODE_TV_INPUT_HDMI_1 | +| 244 | KEYCODE_TV_INPUT_HDMI_2 | +| 245 | KEYCODE_TV_INPUT_HDMI_3 | +| 246 | KEYCODE_TV_INPUT_HDMI_4 | +| 247 | KEYCODE_TV_INPUT_COMPOSITE_1 | +| 248 | KEYCODE_TV_INPUT_COMPOSITE_2 | +| 249 | KEYCODE_TV_INPUT_COMPONENT_1 | +| 250 | KEYCODE_TV_INPUT_COMPONENT_2 | +| 251 | KEYCODE_TV_INPUT_VGA_1 | +| 252 | KEYCODE_TV_AUDIO_DESCRIPTION | +| 253 | KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP | +| 254 | KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN | +| 255 | KEYCODE_TV_ZOOM_MODE | +| 256 | KEYCODE_TV_CONTENTS_MENU | +| 257 | KEYCODE_TV_MEDIA_CONTEXT_MENU | +| 258 | KEYCODE_TV_TIMER_PROGRAMMING | +| 259 | KEYCODE_HELP | +| 260 | KEYCODE_NAVIGATE_PREVIOUS | +| 261 | KEYCODE_NAVIGATE_NEXT | +| 262 | KEYCODE_NAVIGATE_IN | +| 263 | KEYCODE_NAVIGATE_OUT | +| 264 | KEYCODE_STEM_PRIMARY | +| 265 | KEYCODE_STEM_1 | +| 266 | KEYCODE_STEM_2 | +| 267 | KEYCODE_STEM_3 | +| 268 | KEYCODE_DPAD_UP_LEFT | +| 269 | KEYCODE_DPAD_DOWN_LEFT | +| 270 | KEYCODE_DPAD_UP_RIGHT | +| 271 | KEYCODE_DPAD_DOWN_RIGHT | +| 272 | KEYCODE_MEDIA_SKIP_FORWARD | +| 273 | KEYCODE_MEDIA_SKIP_BACKWARD | +| 274 | KEYCODE_MEDIA_STEP_FORWARD | +| 275 | KEYCODE_MEDIA_STEP_BACKWARD | +| 276 | KEYCODE_SOFT_SLEEP | +| 277 | KEYCODE_CUT | +| 278 | KEYCODE_COPY | +| 279 | KEYCODE_PASTE | +| 280 | KEYCODE_SYSTEM_NAVIGATION_UP | +| 281 | KEYCODE_SYSTEM_NAVIGATION_DOWN | +| 282 | KEYCODE_SYSTEM_NAVIGATION_LEFT | +| 283 | KEYCODE_SYSTEM_NAVIGATION_RIGHT | +| 284 | KEYCODE_ALL_APPS | +| 285 | KEYCODE_REFRESH | +| 286 | KEYCODE_THUMBS_UP | +| 287 | KEYCODE_THUMBS_DOWN | +| 288 | KEYCODE_PROFILE_SWITCH | +| 289 | KEYCODE_VIDEO_APP_1 | +| 290 | KEYCODE_VIDEO_APP_2 | +| 291 | KEYCODE_VIDEO_APP_3 | +| 292 | KEYCODE_VIDEO_APP_4 | +| 293 | KEYCODE_VIDEO_APP_5 | +| 294 | KEYCODE_VIDEO_APP_6 | +| 295 | KEYCODE_VIDEO_APP_7 | +| 296 | KEYCODE_VIDEO_APP_8 | +| 297 | KEYCODE_FEATURED_APP_1 | +| 298 | KEYCODE_FEATURED_APP_2 | +| 299 | KEYCODE_FEATURED_APP_3 | +| 300 | KEYCODE_FEATURED_APP_4 | +| 301 | KEYCODE_DEMO_APP_1 | +| 302 | KEYCODE_DEMO_APP_2 | +| 303 | KEYCODE_DEMO_APP_3 | +| 304 | KEYCODE_DEMO_APP_4 | + diff --git a/bundles/org.openhab.binding.androidtv/pom.xml b/bundles/org.openhab.binding.androidtv/pom.xml new file mode 100644 index 0000000000000..e3a2936ac4a92 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/pom.xml @@ -0,0 +1,32 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.0.0-SNAPSHOT + + + org.openhab.binding.androidtv + + openHAB Add-ons :: Bundles :: AndroidTV Binding + + + + org.bouncycastle + bcpkix-jdk15on + 1.52 + compile + + + org.bouncycastle + bcprov-jdk15on + 1.52 + compile + + + + diff --git a/bundles/org.openhab.binding.androidtv/src/main/feature/feature.xml b/bundles/org.openhab.binding.androidtv/src/main/feature/feature.xml new file mode 100644 index 0000000000000..ae915f51ee9ec --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + openhab-transport-mdns + mvn:org.openhab.addons.bundles/org.openhab.binding.androidtv/${project.version} + + diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVBindingConstants.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVBindingConstants.java new file mode 100644 index 0000000000000..0838f1753e64d --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVBindingConstants.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link AndroidTVBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class AndroidTVBindingConstants { + + private static final String BINDING_ID = "androidtv"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_GOOGLETV = new ThingTypeUID(BINDING_ID, "googletv"); + public static final ThingTypeUID THING_TYPE_SHIELDTV = new ThingTypeUID(BINDING_ID, "shieldtv"); + + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_GOOGLETV, THING_TYPE_SHIELDTV); + + // List of all Channel ids + public static final String CHANNEL_DEBUG = "debug"; + public static final String CHANNEL_KEYBOARD = "keyboard"; + public static final String CHANNEL_KEYPRESS = "keypress"; + public static final String CHANNEL_KEYCODE = "keycode"; + public static final String CHANNEL_PINCODE = "pincode"; + public static final String CHANNEL_APP = "app"; + public static final String CHANNEL_APPNAME = "appname"; + public static final String CHANNEL_APPURL = "appurl"; + public static final String CHANNEL_POWER = "power"; + public static final String CHANNEL_VOLUME = "volume"; + public static final String CHANNEL_MUTE = "mute"; + public static final String CHANNEL_PLAYER = "player"; + + // List of all config properties + public static final String PROPERTY_IP_ADDRESS = "ipAddress"; + + // List of all static String literals + public static final String PIN_REQUEST = "REQUEST"; +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVDynamicCommandDescriptionProvider.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVDynamicCommandDescriptionProvider.java new file mode 100644 index 0000000000000..9ac7a5fa1fc85 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVDynamicCommandDescriptionProvider.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.thing.binding.BaseDynamicCommandDescriptionProvider; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.DynamicCommandDescriptionProvider; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * Dynamic provider of command options. + * + * @author Ben Rosenblum - Initial contribution + * + * Originally written for ADB by Christoph Weitkamp - Initial contribution + */ +@Component(service = { DynamicCommandDescriptionProvider.class, AndroidTVDynamicCommandDescriptionProvider.class }) +@NonNullByDefault +public class AndroidTVDynamicCommandDescriptionProvider extends BaseDynamicCommandDescriptionProvider { + @Activate + public AndroidTVDynamicCommandDescriptionProvider(final @Reference EventPublisher eventPublisher, // + final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, // + final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.eventPublisher = eventPublisher; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandler.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandler.java new file mode 100644 index 0000000000000..f43936324250f --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandler.java @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.androidtv.internal.protocol.googletv.GoogleTVConfiguration; +import org.openhab.binding.androidtv.internal.protocol.googletv.GoogleTVConnectionManager; +import org.openhab.binding.androidtv.internal.protocol.shieldtv.ShieldTVConfiguration; +import org.openhab.binding.androidtv.internal.protocol.shieldtv.ShieldTVConnectionManager; +import org.openhab.core.library.types.StringType; +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.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.CommandOption; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AndroidTVHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * Significant portions reused from Lutron binding with permission from Bob A. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class AndroidTVHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(AndroidTVHandler.class); + + private @Nullable ShieldTVConnectionManager shieldtvConnectionManager; + private @Nullable GoogleTVConnectionManager googletvConnectionManager; + + private @Nullable ScheduledFuture monitorThingStatusJob; + private final Object monitorThingStatusJobLock = new Object(); + private static final int THING_STATUS_FREQUENCY = 250; + + private final AndroidTVDynamicCommandDescriptionProvider commandDescriptionProvider; + private final ThingTypeUID thingTypeUID; + private final String thingID; + + public AndroidTVHandler(Thing thing, AndroidTVDynamicCommandDescriptionProvider commandDescriptionProvider, + ThingTypeUID thingTypeUID) { + super(thing); + this.commandDescriptionProvider = commandDescriptionProvider; + this.thingTypeUID = thingTypeUID; + this.thingID = this.getThing().getUID().getId(); + } + + public void setThingProperty(String property, String value) { + thing.setProperty(property, value); + } + + public String getThingID() { + return this.thingID; + } + + public void updateChannelState(String channel, State state) { + updateState(channel, state); + } + + public ScheduledExecutorService getScheduler() { + return scheduler; + } + + public void updateCDP(String channelName, Map cdpMap) { + logger.trace("{} - Updating CDP for {}", this.thingID, channelName); + List commandOptions = new ArrayList(); + cdpMap.forEach((key, value) -> commandOptions.add(new CommandOption(key, value))); + logger.trace("{} - CDP List: {}", this.thingID, commandOptions); + commandDescriptionProvider.setCommandOptions(new ChannelUID(getThing().getUID(), channelName), commandOptions); + } + + private void monitorThingStatus() { + synchronized (monitorThingStatusJobLock) { + checkThingStatus(); + monitorThingStatusJob = scheduler.schedule(this::monitorThingStatus, THING_STATUS_FREQUENCY, + TimeUnit.MILLISECONDS); + } + } + + public void checkThingStatus() { + String statusMessage = ""; + boolean failed = false; + + GoogleTVConnectionManager googletvConnectionManager = this.googletvConnectionManager; + ShieldTVConnectionManager shieldtvConnectionManager = this.shieldtvConnectionManager; + + if (googletvConnectionManager != null) { + if (!googletvConnectionManager.getLoggedIn()) { + statusMessage = "GoogleTV: " + googletvConnectionManager.getStatusMessage(); + failed = true; + } else { + statusMessage = "GoogleTV: ONLINE"; + } + } + + if (THING_TYPE_SHIELDTV.equals(thingTypeUID)) { + if (shieldtvConnectionManager != null) { + if (!shieldtvConnectionManager.getLoggedIn()) { + statusMessage = statusMessage + " | ShieldTV: " + shieldtvConnectionManager.getStatusMessage(); + failed = true; + } else { + statusMessage = statusMessage + " | ShieldTV: ONLINE"; + } + } + } + + if (failed) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, statusMessage); + } else { + updateStatus(ThingStatus.ONLINE); + } + } + + @Override + public void initialize() { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.protocols-starting"); + + GoogleTVConfiguration googletvConfig = getConfigAs(GoogleTVConfiguration.class); + String ipAddress = googletvConfig.ipAddress; + + if (ipAddress.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.googletv-address-not-specified"); + return; + } + + googletvConnectionManager = new GoogleTVConnectionManager(this, googletvConfig); + + if (THING_TYPE_SHIELDTV.equals(thingTypeUID)) { + ShieldTVConfiguration shieldtvConfig = getConfigAs(ShieldTVConfiguration.class); + ipAddress = shieldtvConfig.ipAddress; + + if (ipAddress.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.shieldtv-address-not-specified"); + return; + } + + shieldtvConnectionManager = new ShieldTVConnectionManager(this, shieldtvConfig); + } + + monitorThingStatusJob = scheduler.schedule(this::monitorThingStatus, THING_STATUS_FREQUENCY, + TimeUnit.MILLISECONDS); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.trace("{} - Command received at handler: {} {}", this.thingID, channelUID.getId(), command); + + if (command.toString().equals("REFRESH")) { + // REFRESH causes issues on some channels. Block for now until implemented. + return; + } + + GoogleTVConnectionManager googletvConnectionManager = this.googletvConnectionManager; + ShieldTVConnectionManager shieldtvConnectionManager = this.shieldtvConnectionManager; + + if (CHANNEL_DEBUG.equals(channelUID.getId())) { + if (command instanceof StringType) { + if (command.toString().equals("GOOGLETV_HALT") && (googletvConnectionManager != null)) { + googletvConnectionManager.dispose(); + googletvConnectionManager = null; + } else if (command.toString().equals("GOOGLETV_START")) { + GoogleTVConfiguration googletvConfig = getConfigAs(GoogleTVConfiguration.class); + googletvConnectionManager = new GoogleTVConnectionManager(this, googletvConfig); + } else if (command.toString().equals("GOOGLETV_SHIM") && (googletvConnectionManager == null)) { + GoogleTVConfiguration googletvConfig = getConfigAs(GoogleTVConfiguration.class); + googletvConfig.shim = true; + googletvConnectionManager = new GoogleTVConnectionManager(this, googletvConfig); + } else if (command.toString().equals("SHIELDTV_HALT") && (shieldtvConnectionManager != null)) { + shieldtvConnectionManager.dispose(); + shieldtvConnectionManager = null; + } else if (command.toString().equals("SHIELDTV_START")) { + ShieldTVConfiguration shieldtvConfig = getConfigAs(ShieldTVConfiguration.class); + shieldtvConnectionManager = new ShieldTVConnectionManager(this, shieldtvConfig); + } else if (command.toString().equals("SHIELDTV_SHIM") && (shieldtvConnectionManager == null)) { + ShieldTVConfiguration shieldtvConfig = getConfigAs(ShieldTVConfiguration.class); + shieldtvConfig.shim = true; + shieldtvConnectionManager = new ShieldTVConnectionManager(this, shieldtvConfig); + } else if (command.toString().startsWith("GOOGLETV") && (googletvConnectionManager != null)) { + googletvConnectionManager.handleCommand(channelUID, command); + } else if (command.toString().startsWith("SHIELDTV") && (shieldtvConnectionManager != null)) { + shieldtvConnectionManager.handleCommand(channelUID, command); + } + } + return; + } + + if (THING_TYPE_SHIELDTV.equals(thingTypeUID) && (shieldtvConnectionManager != null)) { + if (CHANNEL_PINCODE.equals(channelUID.getId())) { + if (command instanceof StringType) { + if (!shieldtvConnectionManager.getLoggedIn()) { + shieldtvConnectionManager.handleCommand(channelUID, command); + return; + } + } + } else if (CHANNEL_APP.equals(channelUID.getId())) { + if (command instanceof StringType) { + shieldtvConnectionManager.handleCommand(channelUID, command); + return; + } + } + } + + if (googletvConnectionManager != null) { + googletvConnectionManager.handleCommand(channelUID, command); + return; + } + + logger.warn("{} - Commands All Failed. Please report this as a bug. {} {}", thingID, channelUID.getId(), + command); + } + + @Override + public void dispose() { + synchronized (monitorThingStatusJobLock) { + ScheduledFuture monitorThingStatusJob = this.monitorThingStatusJob; + if (monitorThingStatusJob != null) { + monitorThingStatusJob.cancel(true); + } + } + + GoogleTVConnectionManager googletvConnectionManager = this.googletvConnectionManager; + ShieldTVConnectionManager shieldtvConnectionManager = this.shieldtvConnectionManager; + + if (shieldtvConnectionManager != null) { + shieldtvConnectionManager.dispose(); + } + + if (googletvConnectionManager != null) { + googletvConnectionManager.dispose(); + } + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandlerFactory.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandlerFactory.java new file mode 100644 index 0000000000000..d5690ddc4e584 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandlerFactory.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; + +import java.util.Set; + +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.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link AndroidTVHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.androidtv", service = ThingHandlerFactory.class) +public class AndroidTVHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_GOOGLETV, + THING_TYPE_SHIELDTV); + + private final AndroidTVDynamicCommandDescriptionProvider commandDescriptionProvider; + + @Activate + public AndroidTVHandlerFactory( + final @Reference AndroidTVDynamicCommandDescriptionProvider commandDescriptionProvider) { + this.commandDescriptionProvider = commandDescriptionProvider; + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + return new AndroidTVHandler(thing, commandDescriptionProvider, thingTypeUID); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/GoogleTVDiscoveryParticipant.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/GoogleTVDiscoveryParticipant.java new file mode 100644 index 0000000000000..96c3852ce0687 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/GoogleTVDiscoveryParticipant.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.discovery; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; + +import java.net.InetAddress; +import java.util.Map; +import java.util.Set; + +import javax.jmdns.ServiceInfo; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of {@link MDNSDiscoveryParticipant} that will discover GOOGLETV(s). + * + * @author Ben Rosenblum - initial contribution + */ +@NonNullByDefault +@Component(service = MDNSDiscoveryParticipant.class, immediate = true, configurationPid = "discovery.googletv") +public class GoogleTVDiscoveryParticipant implements MDNSDiscoveryParticipant { + + private final Logger logger = LoggerFactory.getLogger(GoogleTVDiscoveryParticipant.class); + private static final String GOOGLETV_MDNS_SERVICE_TYPE = "_androidtvremote2._tcp.local."; + + @Override + public Set getSupportedThingTypeUIDs() { + return SUPPORTED_THING_TYPES; + } + + @Override + public String getServiceType() { + return GOOGLETV_MDNS_SERVICE_TYPE; + } + + @Override + public @Nullable DiscoveryResult createResult(@Nullable ServiceInfo service) { + if ((service == null) || !service.hasData()) { + return null; + } + + InetAddress[] ipAddresses = service.getInet4Addresses(); + + if (ipAddresses.length > 0) { + String ipAddress = ipAddresses[0].getHostAddress(); + String macAddress = service.getPropertyString("bt"); + + if (logger.isDebugEnabled()) { + String nice = service.getNiceTextString(); + String qualifiedName = service.getQualifiedName(); + logger.debug("GoogleTV mDNS discovery notified of GoogleTV mDNS service: {}", nice); + logger.trace("GoogleTV mDNS service qualifiedName: {}", qualifiedName); + logger.trace("GoogleTV mDNS service ipAddresses: {} ({})", ipAddresses, ipAddresses.length); + logger.trace("GoogleTV mDNS service selected ipAddress: {}", ipAddress); + logger.trace("GoogleTV mDNS service property macAddress: {}", macAddress); + } + + final ThingUID uid = getThingUID(service); + if (uid != null) { + final String id = uid.getId(); + final String label = service.getName() + " (" + id + ")"; + final Map properties = Map.of(PROPERTY_IP_ADDRESS, ipAddress); + + return DiscoveryResultBuilder.create(uid).withProperties(properties).withLabel(label).build(); + } else { + return null; + } + } else { + return null; + } + } + + @Override + public @Nullable ThingUID getThingUID(@Nullable ServiceInfo service) { + if ((service == null) || !service.hasData() || (service.getPropertyString("bt") == null)) { + return null; + } + + return new ThingUID(THING_TYPE_GOOGLETV, service.getPropertyString("bt").replace(":", "")); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/ShieldTVDiscoveryParticipant.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/ShieldTVDiscoveryParticipant.java new file mode 100644 index 0000000000000..31c292522a624 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/ShieldTVDiscoveryParticipant.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.discovery; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; + +import java.net.InetAddress; +import java.util.Map; +import java.util.Set; + +import javax.jmdns.ServiceInfo; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of {@link MDNSDiscoveryParticipant} that will discover SHIELDTV(s). + * + * @author Ben Rosenblum - initial contribution + */ +@NonNullByDefault +@Component(service = MDNSDiscoveryParticipant.class, immediate = true, configurationPid = "discovery.shieldtv") +public class ShieldTVDiscoveryParticipant implements MDNSDiscoveryParticipant { + + private final Logger logger = LoggerFactory.getLogger(ShieldTVDiscoveryParticipant.class); + private static final String SHIELDTV_MDNS_SERVICE_TYPE = "_nv_shield_remote._tcp.local."; + + @Override + public Set getSupportedThingTypeUIDs() { + return SUPPORTED_THING_TYPES; + } + + @Override + public String getServiceType() { + return SHIELDTV_MDNS_SERVICE_TYPE; + } + + @Override + public @Nullable DiscoveryResult createResult(@Nullable ServiceInfo service) { + if (service == null || !service.hasData()) { + return null; + } + + InetAddress[] ipAddresses = service.getInet4Addresses(); + + if (ipAddresses.length > 0) { + String ipAddress = ipAddresses[0].getHostAddress(); + String serverId = service.getPropertyString("SERVER"); + String serverCapability = service.getPropertyString("SERVER_CAPABILITY"); + + if (logger.isDebugEnabled()) { + String nice = service.getNiceTextString(); + String qualifiedName = service.getQualifiedName(); + logger.debug("ShieldTV mDNS discovery notified of ShieldTV mDNS service: {}", nice); + logger.trace("ShieldTV mDNS service qualifiedName: {}", qualifiedName); + logger.trace("ShieldTV mDNS service ipAddresses: {} ({})", ipAddresses, ipAddresses.length); + logger.trace("ShieldTV mDNS service selected ipAddress: {}", ipAddress); + logger.trace("ShieldTV mDNS service property SERVER: {}", serverId); + logger.trace("ShieldTV mDNS service property SERVER_CAPABILITY: {}", serverCapability); + } + + final ThingUID uid = getThingUID(service); + if (uid != null) { + final String id = uid.getId(); + final String label = service.getName() + " (" + id + ")"; + final Map properties = Map.of(PROPERTY_IP_ADDRESS, ipAddress); + + return DiscoveryResultBuilder.create(uid).withProperties(properties).withLabel(label).build(); + } else { + return null; + } + } else { + return null; + } + } + + @Override + public @Nullable ThingUID getThingUID(@Nullable ServiceInfo service) { + if (service == null || !service.hasData() || (service.getPropertyString("SERVER") == null)) { + return null; + } + + return new ThingUID(THING_TYPE_SHIELDTV, service.getPropertyString("SERVER").substring(8)); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVCommand.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVCommand.java new file mode 100644 index 0000000000000..6efcabfde8a0d --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVCommand.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * GoogleTVCommand represents a GoogleTV protocol command + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class GoogleTVCommand { + private String command; + + public GoogleTVCommand(String command) { + this.command = command; + } + + @Override + public String toString() { + return command; + } + + public boolean isEmpty() { + return command.isEmpty(); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConfiguration.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConfiguration.java new file mode 100644 index 0000000000000..91fb0d685a525 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConfiguration.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link GoogleTVConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class GoogleTVConfiguration { + + public String ipAddress = ""; + public int port = 6466; + public int reconnect; + public int heartbeat; + public String keystoreFileName = ""; + public String keystorePassword = ""; + public int delay = 0; + public boolean shim; + public boolean shimNewKeys; + public String mode = ""; +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConnectionManager.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConnectionManager.java new file mode 100644 index 0000000000000..d604654599480 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConnectionManager.java @@ -0,0 +1,1385 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; +import static org.openhab.binding.androidtv.internal.protocol.googletv.GoogleTVConstants.*; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.InterruptedIOException; +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.NoRouteToHostException; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.androidtv.internal.AndroidTVHandler; +import org.openhab.binding.androidtv.internal.utils.AndroidTVPKI; +import org.openhab.core.OpenHAB; +import org.openhab.core.library.types.NextPreviousType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.RewindFastforwardType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link GoogleTVConnectionManager} is responsible for handling connections via the googletv protocol + * + * Significant portions reused from Lutron binding with permission from Bob A. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class GoogleTVConnectionManager { + private static final int DEFAULT_RECONNECT_SECONDS = 60; + private static final int DEFAULT_HEARTBEAT_SECONDS = 5; + private static final long KEEPALIVE_TIMEOUT_SECONDS = 30; + private static final String DEFAULT_KEYSTORE_PASSWORD = "secret"; + private static final String DEFAULT_MODE = "NORMAL"; + private static final String PIN_MODE = "PIN"; + private static final int DEFAULT_PORT = 6466; + private static final int PIN_DELAY = 1000; + + private final Logger logger = LoggerFactory.getLogger(GoogleTVConnectionManager.class); + + private ScheduledExecutorService scheduler; + + private final AndroidTVHandler handler; + private GoogleTVConfiguration config; + + private @NonNullByDefault({}) SSLSocketFactory sslSocketFactory; + private @Nullable SSLSocket sslSocket; + private @Nullable BufferedWriter writer; + private @Nullable BufferedReader reader; + + private @NonNullByDefault({}) SSLServerSocketFactory sslServerSocketFactory; + private @Nullable Socket shimServerSocket; + private @Nullable BufferedWriter shimWriter; + private @Nullable BufferedReader shimReader; + + private @Nullable GoogleTVConnectionManager connectionManager; + private @Nullable GoogleTVConnectionManager childConnectionManager; + private @NonNullByDefault({}) GoogleTVMessageParser messageParser; + + private final BlockingQueue sendQueue = new LinkedBlockingQueue<>(); + private final BlockingQueue shimQueue = new LinkedBlockingQueue<>(); + + private @Nullable Future asyncInitializeTask; + private @Nullable Future shimAsyncInitializeTask; + + private @Nullable Thread senderThread; + private @Nullable Thread readerThread; + private @Nullable Thread shimSenderThread; + private @Nullable Thread shimReaderThread; + + private @Nullable ScheduledFuture keepAliveJob; + private @Nullable ScheduledFuture keepAliveReconnectJob; + private @Nullable ScheduledFuture connectRetryJob; + private final Object keepAliveReconnectLock = new Object(); + private final Object connectionLock = new Object(); + + private @Nullable ScheduledFuture deviceHealthJob; + private boolean isOnline = true; + + private StringBuffer sbReader = new StringBuffer(); + private StringBuffer sbShimReader = new StringBuffer(); + private String thisMsg = ""; + + private X509Certificate @Nullable [] shimX509ClientChain; + private Certificate @Nullable [] shimClientChain; + private Certificate @Nullable [] shimServerChain; + private Certificate @Nullable [] shimClientLocalChain; + + private boolean disposing = false; + private boolean isLoggedIn = false; + private String statusMessage = ""; + private String pinHash = ""; + private String shimPinHash = ""; + + private boolean power = false; + private String volCurr = "00"; + private String volMax = "ff"; + private boolean volMute = false; + private String audioMode = ""; + private String currentApp = ""; + private String manufacturer = ""; + private String model = ""; + private String androidVersion = ""; + private String remoteServer = ""; + private String remoteServerVersion = ""; + + private AndroidTVPKI androidtvPKI = new AndroidTVPKI(); + private byte[] encryptionKey; + + public GoogleTVConnectionManager(AndroidTVHandler handler, GoogleTVConfiguration config) { + messageParser = new GoogleTVMessageParser(this); + this.config = config; + this.handler = handler; + this.connectionManager = this; + this.scheduler = handler.getScheduler(); + this.encryptionKey = androidtvPKI.generateEncryptionKey(); + initialize(); + } + + public GoogleTVConnectionManager(AndroidTVHandler handler, GoogleTVConfiguration config, + GoogleTVConnectionManager connectionManager) { + messageParser = new GoogleTVMessageParser(this); + this.config = config; + this.handler = handler; + this.connectionManager = connectionManager; + this.scheduler = handler.getScheduler(); + this.encryptionKey = androidtvPKI.generateEncryptionKey(); + initialize(); + } + + public String getThingID() { + return handler.getThingID(); + } + + public void setManufacturer(String manufacturer) { + this.manufacturer = manufacturer; + handler.setThingProperty("manufacturer", manufacturer); + } + + public String getManufacturer() { + return manufacturer; + } + + public void setModel(String model) { + this.model = model; + handler.setThingProperty("model", model); + } + + public String getModel() { + return model; + } + + public void setAndroidVersion(String androidVersion) { + this.androidVersion = androidVersion; + handler.setThingProperty("androidVersion", androidVersion); + } + + public String getAndroidVersion() { + return androidVersion; + } + + public void setRemoteServer(String remoteServer) { + this.remoteServer = remoteServer; + handler.setThingProperty("remoteServer", remoteServer); + } + + public String getRemoteServer() { + return remoteServer; + } + + public void setRemoteServerVersion(String remoteServerVersion) { + this.remoteServerVersion = remoteServerVersion; + handler.setThingProperty("remoteServerVersion", remoteServerVersion); + } + + public String getRemoteServerVersion() { + return remoteServerVersion; + } + + public void setPower(boolean power) { + this.power = power; + logger.debug("{} - Setting power to {}", handler.getThingID(), power); + if (power) { + handler.updateChannelState(CHANNEL_POWER, OnOffType.ON); + } else { + handler.updateChannelState(CHANNEL_POWER, OnOffType.OFF); + } + } + + public boolean getPower() { + return power; + } + + public void setVolCurr(String volCurr) { + this.volCurr = volCurr; + int max = Integer.parseInt(this.volMax, 16); + int volume = ((Integer.parseInt(volCurr, 16) * 100) / max); + handler.updateChannelState(CHANNEL_VOLUME, new PercentType(volume)); + } + + public String getVolCurr() { + return volCurr; + } + + public void setVolMax(String volMax) { + this.volMax = volMax; + } + + public String getVolMax() { + return volMax; + } + + public void setVolMute(String volMute) { + if (DELIMITER_00.equals(volMute)) { + this.volMute = false; + handler.updateChannelState(CHANNEL_MUTE, OnOffType.OFF); + } else if (DELIMITER_01.equals(volMute)) { + this.volMute = true; + handler.updateChannelState(CHANNEL_MUTE, OnOffType.ON); + } + } + + public boolean getVolMute() { + return volMute; + } + + public void setAudioMode(String audioMode) { + this.audioMode = audioMode; + } + + public String getAudioMode() { + return audioMode; + } + + public void setCurrentApp(String currentApp) { + this.currentApp = currentApp; + handler.updateChannelState(CHANNEL_APP, new StringType(currentApp)); + } + + public String getStatusMessage() { + return statusMessage; + } + + private void setStatus(boolean isLoggedIn) { + if (isLoggedIn) { + setStatus(isLoggedIn, "ONLINE"); + } else { + setStatus(isLoggedIn, "UNKNOWN"); + } + } + + private void setStatus(boolean isLoggedIn, String statusMessage) { + if ((this.isLoggedIn != isLoggedIn) || (!this.statusMessage.equals(statusMessage))) { + this.isLoggedIn = isLoggedIn; + this.statusMessage = statusMessage; + handler.checkThingStatus(); + } + } + + public String getCurrentApp() { + return currentApp; + } + + public void setLoggedIn(boolean isLoggedIn) { + if (this.isLoggedIn != isLoggedIn) { + setStatus(isLoggedIn); + } + } + + public boolean getLoggedIn() { + return isLoggedIn; + } + + private boolean servicePing() { + int timeout = 500; + + SocketAddress socketAddress = new InetSocketAddress(config.ipAddress, config.port); + try (Socket socket = new Socket()) { + socket.connect(socketAddress, timeout); + return true; + } catch (ConnectException | SocketTimeoutException | NoRouteToHostException ignored) { + return false; + } catch (IOException ignored) { + // IOException is thrown by automatic close() of the socket. + // This should actually never return a value as we should return true above already + return true; + } + } + + private void checkHealth() { + boolean isOnline; + if (!isLoggedIn) { + isOnline = servicePing(); + } else { + isOnline = true; + } + logger.debug("{} - Device Health - Online: {} - Logged In: {} - Mode: {}", handler.getThingID(), isOnline, + isLoggedIn, config.mode); + if (isOnline != this.isOnline) { + this.isOnline = isOnline; + if (isOnline) { + logger.debug("{} - Device is back online. Attempting reconnection.", handler.getThingID()); + reconnect(); + } + } + } + + private void setShimX509ClientChain(X509Certificate @Nullable [] shimX509ClientChain) { + try { + this.shimX509ClientChain = shimX509ClientChain; + logger.trace("Setting shimX509ClientChain {}", config.port); + if (shimX509ClientChain != null && logger.isTraceEnabled()) { + for (int cert = 0; cert < shimX509ClientChain.length; cert++) { + logger.trace("Subject DN: {}", shimX509ClientChain[cert].getSubjectX500Principal()); + logger.trace("Issuer DN: {}", shimX509ClientChain[cert].getIssuerX500Principal()); + logger.trace("Serial number: {}", shimX509ClientChain[cert].getSerialNumber()); + logger.trace("Cert: {}", GoogleTVRequest + .decodeMessage(GoogleTVUtils.byteArrayToString(shimX509ClientChain[cert].getEncoded()))); + } + } + } catch (CertificateEncodingException e) { + logger.trace("setShimX509ClientChain CertificateEncodingException", e); + } + } + + private void startChildConnectionManager(int port, String mode) { + GoogleTVConfiguration childConfig = new GoogleTVConfiguration(); + childConfig.ipAddress = config.ipAddress; + childConfig.port = port; + childConfig.reconnect = config.reconnect; + childConfig.heartbeat = config.heartbeat; + childConfig.keystoreFileName = config.keystoreFileName; + childConfig.keystorePassword = config.keystorePassword; + childConfig.delay = config.delay; + childConfig.shim = config.shim; + childConfig.mode = mode; + logger.debug("{} - startChildConnectionManager parent config: {} {} {}", handler.getThingID(), config.port, + config.mode, config.shim); + logger.debug("{} - startChildConnectionManager child config: {} {} {}", handler.getThingID(), childConfig.port, + childConfig.mode, childConfig.shim); + childConnectionManager = new GoogleTVConnectionManager(this.handler, childConfig, this); + } + + private TrustManager[] defineNoOpTrustManager() { + return new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) { + logger.debug("Assuming client certificate is valid"); + if (chain != null && logger.isTraceEnabled()) { + for (int cert = 0; cert < chain.length; cert++) { + logger.trace("Subject DN: {}", chain[cert].getSubjectX500Principal()); + logger.trace("Issuer DN: {}", chain[cert].getIssuerX500Principal()); + logger.trace("Serial number: {}", chain[cert].getSerialNumber()); + } + } + } + + @Override + public void checkServerTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) { + logger.debug("Assuming server certificate is valid"); + if (chain != null && logger.isTraceEnabled()) { + for (int cert = 0; cert < chain.length; cert++) { + logger.trace("Subject DN: {}", chain[cert].getSubjectX500Principal()); + logger.trace("Issuer DN: {}", chain[cert].getIssuerX500Principal()); + logger.trace("Serial number: {}", chain[cert].getSerialNumber()); + } + } + } + + @Override + public X509Certificate @Nullable [] getAcceptedIssuers() { + X509Certificate[] x509ClientChain = shimX509ClientChain; + if (x509ClientChain != null && logger.isTraceEnabled()) { + logger.debug("Returning shimX509ClientChain for getAcceptedIssuers"); + for (int cert = 0; cert < x509ClientChain.length; cert++) { + logger.trace("Subject DN: {}", x509ClientChain[cert].getSubjectX500Principal()); + logger.trace("Issuer DN: {}", x509ClientChain[cert].getIssuerX500Principal()); + logger.trace("Serial number: {}", x509ClientChain[cert].getSerialNumber()); + } + return x509ClientChain; + } else { + logger.debug("Returning empty certificate for getAcceptedIssuers"); + return new X509Certificate[0]; + } + } + } }; + } + + private void initialize() { + SSLContext sslContext; + + String folderName = OpenHAB.getUserDataFolder() + "/androidtv"; + File folder = new File(folderName); + + if (!folder.exists()) { + logger.debug("Creating directory {}", folderName); + folder.mkdirs(); + } + + config.port = (config.port > 0) ? config.port : DEFAULT_PORT; + config.reconnect = (config.reconnect > 0) ? config.reconnect : DEFAULT_RECONNECT_SECONDS; + config.heartbeat = (config.heartbeat > 0) ? config.heartbeat : DEFAULT_HEARTBEAT_SECONDS; + config.delay = (config.delay < 0) ? 0 : config.delay; + config.shim = (config.shim) ? true : false; + config.shimNewKeys = (config.shimNewKeys) ? true : false; + config.mode = (!config.mode.equals("")) ? config.mode : DEFAULT_MODE; + + config.keystoreFileName = (!config.keystoreFileName.equals("")) ? config.keystoreFileName + : folderName + "/googletv." + ((config.shim) ? "shim." : "") + handler.getThing().getUID().getId() + + ".keystore"; + config.keystorePassword = (!config.keystorePassword.equals("")) ? config.keystorePassword + : DEFAULT_KEYSTORE_PASSWORD; + + androidtvPKI.setKeystoreFileName(config.keystoreFileName); + androidtvPKI.setAlias("nvidia"); + + if (config.mode.equals(DEFAULT_MODE)) { + deviceHealthJob = scheduler.scheduleWithFixedDelay(this::checkHealth, config.heartbeat, config.heartbeat, + TimeUnit.SECONDS); + } + + try { + File keystoreFile = new File(config.keystoreFileName); + + if (!keystoreFile.exists() || config.shimNewKeys) { + androidtvPKI.generateNewKeyPair(encryptionKey); + androidtvPKI.saveKeyStore(config.keystorePassword, this.encryptionKey); + } else { + androidtvPKI.loadFromKeyStore(config.keystorePassword, this.encryptionKey); + } + + logger.trace("{} - Initializing SSL Context", handler.getThingID()); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(androidtvPKI.getKeyStore(config.keystorePassword, this.encryptionKey), + config.keystorePassword.toCharArray()); + + TrustManager[] trustManagers = defineNoOpTrustManager(); + + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), trustManagers, null); + + sslSocketFactory = sslContext.getSocketFactory(); + if (!config.shim) { + asyncInitializeTask = scheduler.submit(this::connect); + } else { + shimAsyncInitializeTask = scheduler.submit(this::shimInitialize); + } + } catch (NoSuchAlgorithmException | IOException e) { + setStatus(false, "Error initializing keystore"); + logger.debug("Error initializing keystore", e); + } catch (UnrecoverableKeyException e) { + setStatus(false, "Key unrecoverable with supplied password"); + } catch (GeneralSecurityException e) { + logger.debug("General security exception", e); + } catch (Exception e) { + logger.debug("General exception", e); + } + } + + public void connect() { + synchronized (connectionLock) { + if (isOnline || config.mode.equals(PIN_MODE)) { + try { + logger.debug("{} - Opening GoogleTV SSL connection to {}:{} {}", handler.getThingID(), + config.ipAddress, config.port, config.mode); + SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(config.ipAddress, config.port); + sslSocket.startHandshake(); + this.shimServerChain = ((SSLSocket) sslSocket).getSession().getPeerCertificates(); + writer = new BufferedWriter( + new OutputStreamWriter(sslSocket.getOutputStream(), StandardCharsets.ISO_8859_1)); + reader = new BufferedReader( + new InputStreamReader(sslSocket.getInputStream(), StandardCharsets.ISO_8859_1)); + this.sslSocket = sslSocket; + this.sendQueue.clear(); + logger.debug("{} - Connection to {}:{} {} successful", handler.getThingID(), config.ipAddress, + config.port, config.mode); + } catch (UnknownHostException e) { + setStatus(false, "Unknown host"); + logger.debug("{} - Unknown host {}", handler.getThingID(), config.ipAddress); + return; + } catch (IllegalArgumentException e) { + // port out of valid range + setStatus(false, "Invalid port number"); + logger.debug("{} - Invalid port number {}:{}", handler.getThingID(), config.ipAddress, config.port); + return; + } catch (InterruptedIOException e) { + logger.debug("{} - Interrupted while establishing GoogleTV connection", handler.getThingID()); + Thread.currentThread().interrupt(); + return; + } catch (IOException e) { + String message = e.getMessage(); + if ((message != null) && (message.contains("certificate_unknown")) + && (!config.mode.equals(PIN_MODE)) && (!config.shim)) { + setStatus(false, "PIN Process Incomplete"); + logger.debug("{} - GoogleTV PIN Process Incomplete", handler.getThingID()); + reconnectTaskCancel(true); + startChildConnectionManager(this.config.port + 1, PIN_MODE); + } else if ((message != null) && (message.contains("certificate_unknown")) && (config.shim)) { + logger.debug("Shim cert_unknown I/O error while connecting: {}", e.getMessage()); + Socket shimServerSocket = this.shimServerSocket; + if (shimServerSocket != null) { + try { + shimServerSocket.close(); + } catch (IOException ex) { + logger.debug("Error closing GoogleTV SSL socket: {}", ex.getMessage()); + } + this.shimServerSocket = null; + } + } else { + setStatus(false, "Error opening GoogleTV SSL connection. Check log."); + logger.info("{} - Error opening GoogleTV SSL connection to {}:{} {}", handler.getThingID(), + config.ipAddress, config.port, e.getMessage()); + disconnect(false); + scheduleConnectRetry(config.reconnect); // Possibly a temporary problem. Try again later. + } + return; + } + + setStatus(false, "Initializing"); + + logger.trace("{} - Starting Reader Thread for {}:{}", handler.getThingID(), config.ipAddress, + config.port); + + Thread readerThread = new Thread(this::readerThreadJob, "GoogleTV reader " + handler.getThingID()); + readerThread.setDaemon(true); + readerThread.start(); + this.readerThread = readerThread; + + logger.trace("{} - Starting Sender Thread for {}:{}", handler.getThingID(), config.ipAddress, + config.port); + + Thread senderThread = new Thread(this::senderThreadJob, "GoogleTV sender " + handler.getThingID()); + senderThread.setDaemon(true); + senderThread.start(); + this.senderThread = senderThread; + + logger.trace("{} - Checking for PIN MODE for {}:{} {}", handler.getThingID(), config.ipAddress, + config.port, config.mode); + + if (config.mode.equals(PIN_MODE)) { + logger.trace("{} - Sending PIN Login to {}:{}", handler.getThingID(), config.ipAddress, + config.port); + // Send app name and device name + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(GoogleTVRequest.loginRequest(1)))); + // Unknown but required + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(GoogleTVRequest.loginRequest(2)))); + // Don't send pin request yet, let user send REQUEST via PINCODE channel + } else { + logger.trace("{} - Not PIN Mode {}:{} {}", handler.getThingID(), config.ipAddress, config.port, + config.mode); + } + } else { + scheduleConnectRetry(config.reconnect); // Possibly a temporary problem. Try again later. + } + } + } + + public void shimInitialize() { + synchronized (connectionLock) { + AndroidTVPKI shimPKI = new AndroidTVPKI(); + byte[] shimEncryptionKey = shimPKI.generateEncryptionKey(); + SSLContext sslContext; + + try { + shimPKI.generateNewKeyPair(shimEncryptionKey); + // Move this to PKI. Shim requires a trusted cert chain in the keystore. + KeyStore keystore = KeyStore.getInstance("JKS"); + FileInputStream keystoreInputStream = new FileInputStream(config.keystoreFileName); + keystore.load(keystoreInputStream, config.keystorePassword.toCharArray()); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keystore, config.keystorePassword.toCharArray()); + TrustManager[] trustManagers = defineNoOpTrustManager(); + + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), trustManagers, null); + this.sslServerSocketFactory = sslContext.getServerSocketFactory(); + + logger.trace("Opening GoogleTV shim on port {}", config.port); + SSLServerSocket sslServerSocket = (SSLServerSocket) this.sslServerSocketFactory + .createServerSocket(config.port); + if (this.config.mode.equals(DEFAULT_MODE)) { + sslServerSocket.setNeedClientAuth(true); + } else { + sslServerSocket.setWantClientAuth(true); + } + + while (true) { + logger.trace("Waiting for shim connection... {}", config.port); + if (this.config.mode.equals(DEFAULT_MODE) && (childConnectionManager == null)) { + logger.trace("Starting childConnectionManager {}", config.port); + startChildConnectionManager(this.config.port + 1, PIN_MODE); + } + SSLSocket serverSocket = (SSLSocket) sslServerSocket.accept(); + logger.trace("shimInitialize accepted {}", config.port); + try { + serverSocket.startHandshake(); + logger.trace("shimInitialize startHandshake {}", config.port); + connect(); + logger.trace("shimInitialize connected {}", config.port); + + SSLSession session = serverSocket.getSession(); + Certificate[] cchain2 = session.getPeerCertificates(); + this.shimClientChain = cchain2; + Certificate[] cchain3 = session.getLocalCertificates(); + this.shimClientLocalChain = cchain3; + + X509Certificate[] shimX509ClientChain = new X509Certificate[cchain2.length]; + + for (int i = 0; i < cchain2.length; i++) { + logger.trace("Connection from: {}", + ((X509Certificate) cchain2[i]).getSubjectX500Principal()); + shimX509ClientChain[i] = ((X509Certificate) cchain2[i]); + } + + if (this.config.mode.equals(PIN_MODE)) { + this.shimX509ClientChain = shimX509ClientChain; + GoogleTVConnectionManager connectionManager = this.connectionManager; + if (connectionManager != null) { + connectionManager.setShimX509ClientChain(shimX509ClientChain); + } + } + + if (cchain3 != null) { + for (int i = 0; i < cchain3.length; i++) { + logger.trace("Connection from: {}", + ((X509Certificate) cchain3[i]).getSubjectX500Principal()); + } + } + + logger.trace("Peer host is {}", session.getPeerHost()); + logger.trace("Cipher is {}", session.getCipherSuite()); + logger.trace("Protocol is {}", session.getProtocol()); + logger.trace("ID is {}", new BigInteger(session.getId())); + logger.trace("Session created in {}", session.getCreationTime()); + logger.trace("Session accessed in {}", session.getLastAccessedTime()); + + shimWriter = new BufferedWriter( + new OutputStreamWriter(serverSocket.getOutputStream(), StandardCharsets.ISO_8859_1)); + shimReader = new BufferedReader( + new InputStreamReader(serverSocket.getInputStream(), StandardCharsets.ISO_8859_1)); + this.shimServerSocket = serverSocket; + this.shimQueue.clear(); + + Thread readerThread = new Thread(this::shimReaderThreadJob, "GoogleTV shim reader"); + readerThread.setDaemon(true); + readerThread.start(); + this.shimReaderThread = readerThread; + + Thread senderThread = new Thread(this::shimSenderThreadJob, "GoogleTV shim sender"); + senderThread.setDaemon(true); + senderThread.start(); + this.shimSenderThread = senderThread; + } catch (Exception e) { + logger.trace("Shim initalization exception {}", config.port); + logger.trace("Shim initalization exception", e); + } + } + } catch (Exception e) { + logger.trace("Shim initalization exception {}", config.port); + logger.trace("Shim initalization exception", e); + + return; + } + } + } + + private void scheduleConnectRetry(long waitSeconds) { + logger.trace("{} - Scheduling GoogleTV connection retry in {} seconds", handler.getThingID(), waitSeconds); + connectRetryJob = scheduler.schedule(this::connect, waitSeconds, TimeUnit.SECONDS); + } + + /** + * Disconnect from bridge, cancel retry and keepalive jobs, stop reader and writer threads, and + * clean up. + * + * @param interruptAll Set if reconnect task should be interrupted if running. Should be false when calling from + * connect or reconnect, and true when calling from dispose. + */ + private void disconnect(boolean interruptAll) { + synchronized (connectionLock) { + logger.debug("{} - Disconnecting GoogleTV", handler.getThingID()); + + this.isLoggedIn = false; + + ScheduledFuture connectRetryJob = this.connectRetryJob; + if (connectRetryJob != null) { + connectRetryJob.cancel(true); + } + ScheduledFuture keepAliveJob = this.keepAliveJob; + if (keepAliveJob != null) { + keepAliveJob.cancel(true); + } + reconnectTaskCancel(interruptAll); // May be called from keepAliveReconnectJob thread + + Thread senderThread = this.senderThread; + if (senderThread != null && senderThread.isAlive()) { + senderThread.interrupt(); + } + + Thread readerThread = this.readerThread; + if (readerThread != null && readerThread.isAlive()) { + readerThread.interrupt(); + } + + Thread shimSenderThread = this.shimSenderThread; + if (shimSenderThread != null && shimSenderThread.isAlive()) { + shimSenderThread.interrupt(); + } + + Thread shimReaderThread = this.shimReaderThread; + if (shimReaderThread != null && shimReaderThread.isAlive()) { + shimReaderThread.interrupt(); + } + + SSLSocket sslSocket = this.sslSocket; + if (sslSocket != null) { + try { + sslSocket.close(); + } catch (IOException e) { + logger.debug("Error closing GoogleTV SSL socket: {}", e.getMessage()); + } + this.sslSocket = null; + } + BufferedReader reader = this.reader; + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + logger.debug("Error closing reader: {}", e.getMessage()); + } + } + BufferedWriter writer = this.writer; + if (writer != null) { + try { + writer.close(); + } catch (IOException e) { + logger.debug("Error closing writer: {}", e.getMessage()); + } + } + + Socket shimServerSocket = this.shimServerSocket; + if (shimServerSocket != null) { + try { + shimServerSocket.close(); + } catch (IOException e) { + logger.debug("Error closing GoogleTV SSL socket: {}", e.getMessage()); + } + this.shimServerSocket = null; + } + BufferedReader shimReader = this.shimReader; + if (shimReader != null) { + try { + shimReader.close(); + } catch (IOException e) { + logger.debug("Error closing shimReader: {}", e.getMessage()); + } + } + BufferedWriter shimWriter = this.shimWriter; + if (shimWriter != null) { + try { + shimWriter.close(); + } catch (IOException e) { + logger.debug("Error closing shimWriter: {}", e.getMessage()); + } + } + } + } + + private void reconnect() { + synchronized (connectionLock) { + if (!this.disposing) { + logger.debug("{} - Attempting to reconnect to the GoogleTV", handler.getThingID()); + setStatus(false, "reconnecting"); + disconnect(false); + connect(); + } + } + } + + /** + * Method executed by the message sender thread (senderThread) + */ + private void senderThreadJob() { + logger.debug("{} - Command sender thread started {}", handler.getThingID(), config.port); + try { + while (!Thread.currentThread().isInterrupted() && writer != null) { + GoogleTVCommand command = sendQueue.take(); + + try { + BufferedWriter writer = this.writer; + if (writer != null) { + logger.trace("{} - Raw GoogleTV command decodes as: {}", handler.getThingID(), + GoogleTVRequest.decodeMessage(command.toString())); + writer.write(command.toString()); + writer.flush(); + } + } catch (InterruptedIOException e) { + logger.debug("Interrupted while sending to GoogleTV"); + setStatus(false, "Interrupted"); + break; // exit loop and terminate thread + } catch (IOException e) { + logger.warn("{} - Communication error, will try to reconnect GoogleTV. Error: {}", + handler.getThingID(), e.getMessage()); + setStatus(false, "Communication error, will try to reconnect"); + sendQueue.add(command); // Requeue command + this.isLoggedIn = false; + reconnect(); + break; // reconnect() will start a new thread; terminate this one + } + if (config.delay > 0) { + Thread.sleep(config.delay); // introduce delay to throttle send rate + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + logger.debug("{} - Command sender thread exiting {}", handler.getThingID(), config.port); + } + } + + private void shimSenderThreadJob() { + logger.debug("Shim sender thread started"); + try { + while (!Thread.currentThread().isInterrupted() && shimWriter != null) { + GoogleTVCommand command = shimQueue.take(); + + try { + BufferedWriter writer = this.shimWriter; + if (writer != null) { + logger.trace("Shim received from google: {}", + GoogleTVRequest.decodeMessage(command.toString())); + writer.write(command.toString()); + writer.flush(); + } + } catch (InterruptedIOException e) { + logger.debug("Shim interrupted while sending."); + break; // exit loop and terminate thread + } catch (IOException e) { + logger.warn("Shim communication error. Error: {}", e.getMessage()); + break; // reconnect() will start a new thread; terminate this one + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + logger.debug("Command sender thread exiting"); + } + } + + /** + * Method executed by the message reader thread (readerThread) + */ + private void readerThreadJob() { + logger.debug("{} - Message reader thread started {}", handler.getThingID(), config.port); + try { + BufferedReader reader = this.reader; + int length = 0; + int current = 0; + while (!Thread.interrupted() && reader != null) { + thisMsg = GoogleTVRequest.fixMessage(Integer.toHexString(reader.read())); + if (HARD_DROP.equals(thisMsg)) { + // Google has crashed the connection. Disconnect hard. + logger.debug("{} - readerThreadJob received ffffffff. Disconnecting hard.", handler.getThingID()); + this.isLoggedIn = false; + reconnect(); + break; + } + if (length == 0) { + length = Integer.parseInt(thisMsg.toString(), 16); + logger.trace("{} - readerThreadJob message length {}", handler.getThingID(), length); + current = 0; + sbReader = new StringBuffer(); + sbReader.append(thisMsg.toString()); + } else { + sbReader.append(thisMsg.toString()); + current += 1; + } + + if ((length > 0) && (current == length)) { + logger.trace("{} - GoogleTV Message: {} {}", handler.getThingID(), length, sbReader.toString()); + messageParser.handleMessage(sbReader.toString()); + if (config.shim) { + String thisCommand = interceptMessages(sbReader.toString()); + shimQueue.add(new GoogleTVCommand(GoogleTVRequest.encodeMessage(thisCommand))); + } + length = 0; + } + } + } catch (InterruptedIOException e) { + logger.debug("Interrupted while reading"); + setStatus(false, "Interrupted"); + } catch (IOException e) { + String message = e.getMessage(); + if ((message != null) && (message.contains("certificate_unknown")) && (!config.mode.equals(PIN_MODE)) + && (!config.shim)) { + setStatus(false, "PIN Process Incomplete"); + logger.debug("{} - GoogleTV PIN Process Incomplete", handler.getThingID()); + reconnectTaskCancel(true); + startChildConnectionManager(this.config.port + 1, PIN_MODE); + } else if ((message != null) && (message.contains("certificate_unknown")) && (config.shim)) { + logger.debug("Shim cert_unknown I/O error while reading from stream: {}", e.getMessage()); + Socket shimServerSocket = this.shimServerSocket; + if (shimServerSocket != null) { + try { + shimServerSocket.close(); + } catch (IOException ex) { + logger.debug("Error closing GoogleTV SSL socket: {}", ex.getMessage()); + } + this.shimServerSocket = null; + } + } else { + logger.debug("I/O error while reading from stream: {}", e.getMessage()); + setStatus(false, "I/O Error"); + } + } catch (RuntimeException e) { + logger.warn("Runtime exception in reader thread", e); + setStatus(false, "Runtime exception"); + } finally { + logger.debug("{} - Message reader thread exiting {}", handler.getThingID(), config.port); + } + } + + private String interceptMessages(String message) { + if (message.startsWith("080210c801c202", 2)) { + // intercept PIN hash and replace with valid shim hash + int length = this.pinHash.length() / 2; + String len1 = GoogleTVRequest.fixMessage(Integer.toHexString(length + 2)); + String len2 = GoogleTVRequest.fixMessage(Integer.toHexString(length)); + String reply = "080210c801c202" + len1 + "0a" + len2 + this.pinHash; + String replyLength = GoogleTVRequest.fixMessage(Integer.toHexString(reply.length() / 2)); + String finalReply = replyLength + reply; + logger.trace("Message Intercepted: {}", message); + logger.trace("Message chagnged to: {}", finalReply); + return finalReply; + } else if (message.startsWith("080210c801ca02", 2)) { + // intercept PIN hash and replace with valid shim hash + int length = this.shimPinHash.length() / 2; + String len1 = GoogleTVRequest.fixMessage(Integer.toHexString(length + 2)); + String len2 = GoogleTVRequest.fixMessage(Integer.toHexString(length)); + String reply = "080210c801ca02" + len1 + "0a" + len2 + this.shimPinHash; + String replyLength = GoogleTVRequest.fixMessage(Integer.toHexString(reply.length() / 2)); + String finalReply = replyLength + reply; + logger.trace("Message Intercepted: {}", message); + logger.trace("Message chagnged to: {}", finalReply); + return finalReply; + } else { + // don't intercept message + return message; + } + } + + private void shimReaderThreadJob() { + logger.debug("Shim reader thread started {}", config.port); + try { + BufferedReader reader = this.shimReader; + String thisShimMsg = ""; + int length = 0; + int current = 0; + while (!Thread.interrupted() && reader != null) { + thisShimMsg = GoogleTVRequest.fixMessage(Integer.toHexString(reader.read())); + if (HARD_DROP.equals(thisShimMsg)) { + // Google has crashed the connection. Disconnect hard. + disconnect(false); + break; + } + if (length == 0) { + length = Integer.parseInt(thisShimMsg.toString(), 16); + logger.trace("shimReaderThreadJob message length {}", length); + current = 0; + sbShimReader = new StringBuffer(); + sbShimReader.append(thisShimMsg.toString()); + } else { + sbShimReader.append(thisShimMsg.toString()); + current += 1; + } + if ((length > 0) && (current == length)) { + logger.trace("Shim GoogleTV Message: {} {}", length, sbShimReader.toString()); + String thisCommand = interceptMessages(sbShimReader.toString()); + sendQueue.add(new GoogleTVCommand(GoogleTVRequest.encodeMessage(thisCommand))); + length = 0; + } + } + } catch (InterruptedIOException e) { + logger.debug("Interrupted while reading"); + setStatus(false, "Interrupted"); + } catch (IOException e) { + logger.debug("I/O error while reading from stream: {}", e.getMessage()); + setStatus(false, "I/O Error"); + } catch (RuntimeException e) { + logger.warn("Runtime exception in reader thread", e); + setStatus(false, "Runtime exception"); + } finally { + logger.debug("Shim message reader thread exiting {}", config.port); + } + } + + public void sendKeepAlive(String request) { + String keepalive = GoogleTVRequest.encodeMessage(GoogleTVRequest.keepAlive(request)); + logger.debug("{} - Sending GoogleTV keepalive - request {} - response {}", handler.getThingID(), request, + GoogleTVRequest.decodeMessage(keepalive)); + sendCommand(new GoogleTVCommand(keepalive)); + reconnectTaskSchedule(); + } + + /** + * Schedules the reconnect task keepAliveReconnectJob to execute in KEEPALIVE_TIMEOUT_SECONDS. This should + * be + * cancelled by calling reconnectTaskCancel() if a valid response is received from the bridge. + */ + private void reconnectTaskSchedule() { + synchronized (keepAliveReconnectLock) { + logger.trace("{} - Scheduling Reconnect Job for {}", handler.getThingID(), KEEPALIVE_TIMEOUT_SECONDS); + keepAliveReconnectJob = scheduler.schedule(this::keepAliveTimeoutExpired, KEEPALIVE_TIMEOUT_SECONDS, + TimeUnit.SECONDS); + } + } + + /** + * Cancels the reconnect task keepAliveReconnectJob. + */ + private void reconnectTaskCancel(boolean interrupt) { + synchronized (keepAliveReconnectLock) { + ScheduledFuture keepAliveReconnectJob = this.keepAliveReconnectJob; + if (keepAliveReconnectJob != null) { + logger.trace("{} - Canceling GoogleTV scheduled reconnect job.", handler.getThingID()); + keepAliveReconnectJob.cancel(interrupt); + this.keepAliveReconnectJob = null; + } + } + } + + /** + * Executed by keepAliveReconnectJob if it is not cancelled by the LEAP message parser calling + * validMessageReceived() which in turn calls reconnectTaskCancel(). + */ + private void keepAliveTimeoutExpired() { + logger.debug("{} - GoogleTV keepalive response timeout expired. Initiating reconnect.", handler.getThingID()); + reconnect(); + } + + public void validMessageReceived() { + reconnectTaskCancel(true); // Got a good message, so cancel reconnect task. + } + + public void finishPinProcess() { + GoogleTVConnectionManager connectionManager = this.connectionManager; + GoogleTVConnectionManager childConnectionManager = this.childConnectionManager; + if ((connectionManager != null) && (config.mode.equals(PIN_MODE)) && (!config.shim)) { + disconnect(false); + connectionManager.finishPinProcess(); + } else if ((childConnectionManager != null) && (config.mode.equals(DEFAULT_MODE)) && (!config.shim)) { + childConnectionManager.dispose(); + reconnect(); + } + } + + public void sendCommand(GoogleTVCommand command) { + if ((!config.shim) && (!command.isEmpty())) { + int length = command.toString().length(); + String hexLength = GoogleTVRequest.encodeMessage(GoogleTVRequest.fixMessage(Integer.toHexString(length))); + String message = hexLength + command.toString(); + GoogleTVCommand lenCommand = new GoogleTVCommand(message); + sendQueue.add(lenCommand); + } + } + + public void sendShim(GoogleTVCommand command) { + if (!command.isEmpty()) { + shimQueue.add(command); + } + } + + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("{} - Command received: {}", handler.getThingID(), channelUID.getId()); + + if (CHANNEL_KEYPRESS.equals(channelUID.getId())) { + if (command instanceof StringType) { + if (command.toString().length() == 5) { + // Account for KEY_(ASCII Character) + String keyPress = "aa01071a0512031a01" + + GoogleTVRequest.decodeMessage(new String("" + command.toString().charAt(4))); + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(keyPress))); + return; + } + + String message = ""; + String suffix = ""; + String shortCommand = command.toString(); + if (command.toString().endsWith("_PRESS")) { + suffix = "1001"; + shortCommand = "KEY_" + command.toString().split("_")[1]; + } else if (command.toString().endsWith("_RELEASE")) { + suffix = "1002"; + shortCommand = "KEY_" + command.toString().split("_")[1]; + } else { + suffix = "1003"; + } + + switch (shortCommand) { + case "KEY_UP": + message = "52040813" + suffix; + break; + case "KEY_DOWN": + message = "52040814" + suffix; + break; + case "KEY_RIGHT": + message = "52040816" + suffix; + break; + case "KEY_LEFT": + message = "52040815" + suffix; + break; + case "KEY_ENTER": + message = "52040817" + suffix; + break; + case "KEY_HOME": + message = "52040803" + suffix; + break; + case "KEY_BACK": + message = "52040804" + suffix; + break; + case "KEY_MENU": + message = "52040852" + suffix; + break; + case "KEY_PLAY": + message = "5204087E" + suffix; + break; + case "KEY_PAUSE": + message = "5204087F" + suffix; + break; + case "KEY_PLAYPAUSE": + message = "52040855" + suffix; + break; + case "KEY_STOP": + message = "52040856" + suffix; + break; + case "KEY_NEXT": + message = "52040857" + suffix; + break; + case "KEY_PREVIOUS": + message = "52040858" + suffix; + break; + case "KEY_REWIND": + message = "52040859" + suffix; + break; + case "KEY_FORWARD": + message = "5204085A" + suffix; + break; + case "KEY_POWER": + message = "5204081a" + suffix; + break; + case "KEY_VOLUP": + message = "52040818" + suffix; + break; + case "KEY_VOLDOWN": + message = "52040819" + suffix; + break; + case "KEY_MUTE": + message = "5204085b" + suffix; + break; + default: + logger.debug("Unknown Key {}", command); + return; + } + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(message))); + } + } else if (CHANNEL_KEYCODE.equals(channelUID.getId())) { + if (command instanceof StringType) { + String shortCommand = command.toString().split("_")[0]; + int commandInt = Integer.parseInt(shortCommand, 10); + String suffix = ""; + if (commandInt > 255) { + suffix = "02"; + commandInt -= 256; + } else if (commandInt > 127) { + suffix = "01"; + } + + String key = Integer.toHexString(commandInt) + suffix; + + if ((key.length() % 2) == 1) { + key = "0" + key; + } + + key = "08" + key; + + if (command.toString().endsWith("_PRESS")) { + key = key + "1001"; + } else if (command.toString().endsWith("_RELEASE")) { + key = key + "1002"; + } else { + key = key + "1003"; + } + + String length = "0" + (key.length() / 2); + String message = "52" + length + key; + + logger.trace("Sending KEYCODE {} as {}", key, message); + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(message))); + } + + } else if (CHANNEL_PINCODE.equals(channelUID.getId())) { + if (command instanceof StringType) { + try { + Certificate[] shimClientChain = this.shimClientChain; + Certificate[] shimServerChain = this.shimServerChain; + Certificate[] shimClientLocalChain = this.shimClientLocalChain; + if (config.mode.equals(DEFAULT_MODE)) { + if ((!isLoggedIn) && (command.toString().equals("REQUEST")) + && (childConnectionManager == null)) { + setStatus(false, "User Forced PIN Process"); + logger.debug("{} - User Forced PIN Process", handler.getThingID()); + disconnect(true); + startChildConnectionManager(config.port + 1, PIN_MODE); + try { + Thread.sleep(PIN_DELAY); + } catch (InterruptedException e) { + logger.trace("InterruptedException", e); + } + } + GoogleTVConnectionManager childConnectionManager = this.childConnectionManager; + if (childConnectionManager != null) { + childConnectionManager.handleCommand(channelUID, command); + } else { + logger.debug("{} - Child Connection Manager unavailable.", handler.getThingID()); + } + } else if ((config.mode.equals(PIN_MODE)) && (!config.shim)) { + if (!isLoggedIn) { + if (command.toString().equals("REQUEST")) { + sendCommand(new GoogleTVCommand( + GoogleTVRequest.encodeMessage(GoogleTVRequest.pinRequest(command.toString())))); + } else if (shimServerChain != null) { + this.pinHash = GoogleTVUtils.validatePIN(command.toString(), androidtvPKI.getCert(), + shimServerChain[0]); + sendCommand(new GoogleTVCommand( + GoogleTVRequest.encodeMessage(GoogleTVRequest.pinRequest(this.pinHash)))); + } + } + } else if ((config.mode.equals(PIN_MODE)) && (config.shim)) { + if ((shimClientChain != null) && (shimServerChain != null) && (shimClientLocalChain != null)) { + this.pinHash = GoogleTVUtils.validatePIN(command.toString(), androidtvPKI.getCert(), + shimServerChain[0]); + this.shimPinHash = GoogleTVUtils.validatePIN(command.toString(), shimClientChain[0], + shimClientLocalChain[0]); + } + } + } catch (CertificateException e) { + logger.trace("PIN CertificateException", e); + } + } + } else if (CHANNEL_POWER.equals(channelUID.getId())) { + if (command instanceof OnOffType) { + if ((power && command.equals(OnOffType.OFF)) || (!power && command.equals(OnOffType.ON))) { + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage("5204081a1003"))); + } + } else if (command instanceof StringType) { + if ((power && command.toString().equals("OFF")) || (!power && command.toString().equals("ON"))) { + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage("5204081a1003"))); + } + } + } else if (CHANNEL_MUTE.equals(channelUID.getId())) { + if (command instanceof OnOffType) { + if ((volMute && command.equals(OnOffType.OFF)) || (!volMute && command.equals(OnOffType.ON))) { + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage("5204085b1003"))); + } + } + } else if (CHANNEL_DEBUG.equals(channelUID.getId())) { + if (command instanceof StringType) { + if (command.toString().startsWith("RAW", 9)) { + String newCommand = command.toString().substring(13); + String message = GoogleTVRequest.encodeMessage(newCommand); + if (logger.isTraceEnabled()) { + logger.trace("Raw Message Decodes as: {}", GoogleTVRequest.decodeMessage(message)); + } + sendCommand(new GoogleTVCommand(message)); + } else if (command.toString().startsWith("MSG", 9)) { + String newCommand = command.toString().substring(13); + messageParser.handleMessage(newCommand); + } + } + } else if (CHANNEL_KEYBOARD.equals(channelUID.getId())) { + if (command instanceof StringType) { + String keyPress = ""; + for (int i = 0; i < command.toString().length(); i++) { + keyPress = "aa01071a0512031a01" + + GoogleTVRequest.decodeMessage(String.valueOf(command.toString().charAt(i))); + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(keyPress))); + } + } + } else if (CHANNEL_PLAYER.equals(channelUID.getId())) { + String message = ""; + if (command == PlayPauseType.PAUSE || command == OnOffType.OFF) { + message = "5204087F1003"; + } else if (command == PlayPauseType.PLAY || command == OnOffType.ON) { + message = "5204087E1003"; + } else if (command == NextPreviousType.NEXT) { + message = "520408571003"; + } else if (command == NextPreviousType.PREVIOUS) { + message = "520408581003"; + } else if (command == RewindFastforwardType.FASTFORWARD) { + message = "5204085A1003"; + } else if (command == RewindFastforwardType.REWIND) { + message = "520408591003"; + } + sendCommand(new GoogleTVCommand(GoogleTVRequest.encodeMessage(message))); + } + } + + public void dispose() { + this.disposing = true; + + Future asyncInitializeTask = this.asyncInitializeTask; + if (asyncInitializeTask != null) { + asyncInitializeTask.cancel(true); // Interrupt async init task if it isn't done yet + } + Future shimAsyncInitializeTask = this.shimAsyncInitializeTask; + if (shimAsyncInitializeTask != null) { + shimAsyncInitializeTask.cancel(true); // Interrupt async init task if it isn't done yet + } + ScheduledFuture deviceHealthJob = this.deviceHealthJob; + if (deviceHealthJob != null) { + deviceHealthJob.cancel(true); + } + GoogleTVConnectionManager childConnectionManager = this.childConnectionManager; + if (childConnectionManager != null) { + childConnectionManager.dispose(); + } + disconnect(true); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConstants.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConstants.java new file mode 100644 index 0000000000000..752a72df6d59b --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConstants.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link GoogleTVConstants} class defines common constants, which are + * used across the googletv protocol. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class GoogleTVConstants { + + // List of all static String literals + public static final String DELIMITER_00 = "00"; + public static final String DELIMITER_01 = "01"; + public static final String DELIMITER_02 = "02"; + public static final String DELIMITER_08 = "08"; + public static final String DELIMITER_0A = "0a"; + public static final String DELIMITER_10 = "10"; + public static final String DELIMITER_12 = "12"; + public static final String DELIMITER_1A = "1a"; + public static final String DELIMITER_42 = "42"; + public static final String DELIMITER_92 = "92"; + public static final String DELIMITER_A2 = "a2"; + public static final String DELIMITER_C2 = "c2"; + + public static final String MESSAGE_POWEROFF = "c202020800"; + public static final String MESSAGE_POWERON = "c202020801"; + public static final String MESSAGE_PINSUCCESS = "080210c801ca02"; + public static final String HARD_DROP = "ffffffff"; +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVMessageParser.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVMessageParser.java new file mode 100644 index 0000000000000..a38f37106effe --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVMessageParser.java @@ -0,0 +1,336 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import static org.openhab.binding.androidtv.internal.protocol.googletv.GoogleTVConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class responsible for parsing incoming GoogleTV messages. Calls back to an object implementing the + * GoogleTVMessageParserCallbacks interface. + * + * Adapted from Lutron Leap binding + * + * @author Ben Rosenblum - Initial contribution + */ + +@NonNullByDefault +public class GoogleTVMessageParser { + private final Logger logger = LoggerFactory.getLogger(GoogleTVMessageParser.class); + + private final GoogleTVConnectionManager callback; + + public GoogleTVMessageParser(GoogleTVConnectionManager callback) { + this.callback = callback; + } + + public void handleMessage(String msg) { + if (msg.trim().isEmpty()) { + return; // Ignore empty lines + } + + String thingId = callback.getThingID(); + char[] charArray = msg.toCharArray(); + String lenString = "" + charArray[0] + charArray[1]; + int len = Integer.parseInt(lenString, 16); + msg = msg.substring(2); + charArray = msg.toCharArray(); + + logger.trace("{} - Received GoogleTV message - Length: {} Message: {}", thingId, len, msg); + + callback.validMessageReceived(); + + try { + if (msg.startsWith(DELIMITER_1A)) { + logger.warn("{} - GoogleTV Error Message: {}", thingId, msg); + } else if (msg.startsWith(DELIMITER_0A)) { + // First message on connection from GTV + // + // 0a 5b08 ff 041256 0a 11 534849454c4420416e64726f6964205456 12 06 4e5649444941 18 01 22 02 3131 2a + // ---------------------LEN-SHIELD Android TV--------------------LEN-NVIDIA---------LEN---LEN-Android + // 24 636f6d2e676f6f676c652e616e64726f69642e74762e72656d6f74652e73657276696365 32 + // LEN-com.google.android.tv.remote.service + // 0d 352e322e343733323534313333 + // LEN-5.2.473254133 + // + // 0a 5308 ff 04124e 0a 0c 42524156494120344b204742 12 04 536f6e79 18 01 22 01 39 2a + // ---------------------LEN-BRAVIA 4K GB---------------LEN-Sony-------LEN---LEN-Android Version + // 24 636f6d2e676f6f676c652e616e64726f69642e74762e72656d6f74652e73657276696365 32 + // 0d 352e322e343733323534313333 + // + // 0a 5408 ff 04124f 0a 0a 4368726f6d6563617374 12 06 476f6f676c65 18 01 22 02 3132 2a + // ---------------------LEN-Chromecast-------------LEN-Google---------LEN---LEN-Android Version + // 24 636f6d2e676f6f676c652e616e64726f69642e74762e72656d6f74652e73657276696365 32 + // 0d 352e322e343733323534313333 + // + // 0a 5708 ff 041252 0a 0d 4368726f6d6563617374204844 12 06 476f6f676c65 18 01 22 02 3132 2a + // ---------------------LEN-Chromecast HD----------------LEN-Google---------LEN---LEN-Android Version + // 24 636f6d2e676f6f676c652e616e64726f69642e74762e72656d6f74652e73657276696365 32 + // 0d352e322e343733323534313333 + + if (callback.getLoggedIn()) { + logger.warn("{} - Unexpected Login Message: {}", thingId, msg); + } else { + callback.sendCommand( + new GoogleTVCommand(GoogleTVRequest.encodeMessage(GoogleTVRequest.loginRequest(4)))); + } + + String st = ""; + int length = 0; + StringBuilder preambleSb = new StringBuilder(); + StringBuilder manufacturerSb = new StringBuilder(); + StringBuilder modelSb = new StringBuilder(); + StringBuilder androidVersionSb = new StringBuilder(); + StringBuilder remoteServerSb = new StringBuilder(); + StringBuilder remoteServerVersionSb = new StringBuilder(); + + int i = 0; + int current = 0; + + for (; i < 14; i++) { + preambleSb.append(charArray[i]); + } + + i += 2; // 0a delimiter + + st = "" + charArray[i] + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + modelSb.append(charArray[i]); + } + + i += 2; // 12 delimiter + + st = "" + charArray[i] + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + manufacturerSb.append(charArray[i]); + } + + i += 6; // 18 01 22 + + st = "" + charArray[i] + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + androidVersionSb.append(charArray[i]); + } + + i += 2; // 2a delimiter + + st = "" + charArray[i] + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + remoteServerSb.append(charArray[i]); + } + + i += 2; // 32 delimiter + + st = "" + charArray[i] + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + remoteServerVersionSb.append(charArray[i]); + } + + String preamble = preambleSb.toString(); + String model = GoogleTVRequest.encodeMessage(modelSb.toString()); + String manufacturer = GoogleTVRequest.encodeMessage(manufacturerSb.toString()); + String androidVersion = GoogleTVRequest.encodeMessage(androidVersionSb.toString()); + String remoteServer = GoogleTVRequest.encodeMessage(remoteServerSb.toString()); + String remoteServerVersion = GoogleTVRequest.encodeMessage(remoteServerVersionSb.toString()); + + logger.debug("{} - {} \"{}\" \"{}\" {} {} {}", thingId, preamble, model, manufacturer, androidVersion, + remoteServer, remoteServerVersion); + + callback.setModel(model); + callback.setManufacturer(manufacturer); + callback.setAndroidVersion(androidVersion); + callback.setRemoteServer(remoteServer); + callback.setRemoteServerVersion(remoteServerVersion); + + } else if (msg.startsWith(DELIMITER_12)) { + // Second message on connection from GTV + // Login successful + callback.sendCommand( + new GoogleTVCommand(GoogleTVRequest.encodeMessage(GoogleTVRequest.loginRequest(5)))); + logger.info("{} - Login Successful", thingId); + callback.setLoggedIn(true); + } else if (msg.startsWith(DELIMITER_92)) { + // Third message on connection from GTV + // Also sent on power state change (to ON only unless keypress triggers)i + // 9203 21 08 02 10 02 1a 11 534849454c4420416e64726f6964205456 20 02 2800 30 0f 38 0e 40 00 + // --------DD----DD----DD-LEN-SHIELD Android TV + // 9203 1e 08 9610 10 09 1a 0d 4368726f6d6563617374204844 20 02 2800 30 19 38 0a 40 00 + // --------DD------DD----DD-LEN-Chromecast HD + // 9203 1a 08 f304 10 09 1a 11 534849454c4420416e64726f6964205456 20 01 + // 9203 1a 08 8205 10 09 1a 11 534849454c4420416e64726f6964205456 20 01 + // --------DD------DD----DD-LEN-SHIELD Android TV + // + // VOLUME: + // ---------------DD----DD----DD-LEN-BRAVIA 4K GB------------DD---------DD-MAX---VOL---MUTE + // 00 --- 9203 1c 08 03 10 06 1a 0c 42524156494120344b204742 20 02 2800 30 64 38 00 40 00 + // 01 --- 9203 1c 08 03 10 06 1a 0c 42524156494120344b204742 20 02 2800 30 64 38 01 40 00 + // 100 -- 9203 1c 08 03 10 06 1a 0c 42524156494120344b204742 20 02 2800 30 64 38 64 40 00 + // MUTE - 9203 1c 08 03 10 06 1a 0c 42524156494120344b204742 20 02 2800 30 64 38 00 40 01 + + String st = ""; + int length = 0; + + StringBuilder preambleSb = new StringBuilder(); + StringBuilder modelSb = new StringBuilder(); + String volMax = ""; + String volCurr = ""; + String volMute = ""; + String audioMode = ""; + + int i = 0; + int current = 0; + + for (; i < 12; i++) { + preambleSb.append(charArray[i]); + } + + st = "" + charArray[i] + charArray[i + 1]; + do { + if (!DELIMITER_1A.equals(st)) { + preambleSb.append(st); + i += 2; + st = "" + charArray[i] + charArray[i + 1]; + } + } while (!DELIMITER_1A.equals(st)); + + i += 2; // 1a delimiter + + st = "" + charArray[i] + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + modelSb.append(charArray[i]); + } + + i += 2; // 20 delimiter + + st = "" + charArray[i] + charArray[i + 1]; + + audioMode = st; // 01 remote audio - 02 local audio + + if (DELIMITER_02.equals(st)) { + i += 2; // 02 longer message + i += 4; // Unknown 2800 message + i += 2; // 30 delimiter + volMax = "" + charArray[i] + charArray[i + 1]; + i += 4; // volMax + 38 delimiter + volCurr = "" + charArray[i] + charArray[i + 1]; + i += 4; // volCurr + 40 delimiter + volMute = "" + charArray[i] + charArray[i + 1]; + + callback.setVolMax(volMax); + callback.setVolCurr(volCurr); + callback.setVolMute(volMute); + } + + String preamble = preambleSb.toString(); + String model = GoogleTVRequest.encodeMessage(modelSb.toString()); + logger.debug("{} - Device Update: {} \"{}\" {} {} {} {}", thingId, preamble, model, audioMode, volMax, + volCurr, volMute); + callback.setAudioMode(audioMode); + + } else if (msg.startsWith(DELIMITER_08)) { + // PIN Process Messages. Only used on 6467. + if (msg.startsWith(MESSAGE_PINSUCCESS)) { + // PIN Process Successful + logger.debug("{} - PIN Process Successful!", thingId); + callback.finishPinProcess(); + } else { + // 080210c801a201081204080310061801 + // 080210c801fa0100 + logger.debug("{} - PIN Intermediary Message: {}", thingId, msg); + } + } else if (msg.startsWith(DELIMITER_C2)) { + // Power State + // c202020800 - OFF + // c202020801 - ON + if (MESSAGE_POWEROFF.equals(msg)) { + callback.setPower(false); + } else if (MESSAGE_POWERON.equals(msg)) { + callback.setPower(true); + } else { + logger.info("{} - Unknown power state received. {}", thingId, msg); + } + } else if (msg.startsWith(DELIMITER_42)) { + // Keepalive request + callback.sendKeepAlive(msg); + } else if (msg.startsWith(DELIMITER_A2)) { + // Current app name. Sent on keypress and power change. + // a201 21 0a 1f 62 1d 636f6d2e676f6f676c652e616e64726f69642e796f75747562652e7476 + // -----------------LEN-com.google.android.youtube.tv + // a201 21 0a 1f 62 1d 636f6d2e676f6f676c652e616e64726f69642e74766c61756e63686572 + // -----------------LEN-com.google.android.tvlauncher + // a201 14 0a 12 62 10 636f6d2e736f6e792e6474762e747678 + // -----------------LEN-com.sony.dtv.tvx + // a201 15 0a 13 62 11 636f6d2e6e6574666c69782e6e696e6a61 + // -----------------LEN-com.netflix.ninja + + StringBuilder preambleSb = new StringBuilder(); + StringBuilder appNameSb = new StringBuilder(); + int i = 0; + int current = 0; + + for (; i < 10; i++) { + preambleSb.append(charArray[i]); + } + + i += 2; // 62 delimiter + + String st = "" + charArray[i] + charArray[i + 1]; + int length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + + for (; i < current + length; i++) { + appNameSb.append(charArray[i]); + } + + String preamble = preambleSb.toString(); + String appName = GoogleTVRequest.encodeMessage(appNameSb.toString()); + + logger.debug("{} - Current App: {} {}", thingId, preamble, appName); + callback.setCurrentApp(appName); + } else { + logger.info("{} - Unknown payload received. {} {}", thingId, len, msg); + } + } catch (Exception e) { + logger.debug("{} - Message Parser Exception on {}", thingId, msg); + logger.debug("Message Parser Caught Exception", e); + } + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVRequest.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVRequest.java new file mode 100644 index 0000000000000..0c976cd236dd1 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVRequest.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; +import static org.openhab.binding.androidtv.internal.protocol.googletv.GoogleTVConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Contains static methods for constructing LEAP messages + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class GoogleTVRequest { + + public static String encodeMessage(String message) { + StringBuilder reply = new StringBuilder(); + char[] charArray = message.toCharArray(); + for (int i = 0; i < charArray.length; i = i + 2) { + String st = "" + charArray[i] + "" + charArray[i + 1]; + char ch = (char) Integer.parseInt(st, 16); + reply.append(ch); + } + return reply.toString(); + } + + public static String decodeMessage(String message) { + StringBuilder sb = new StringBuilder(); + char ch[] = message.toCharArray(); + for (int i = 0; i < ch.length; i++) { + String hexString = Integer.toHexString(ch[i]); + if (hexString.length() % 2 > 0) { + sb.append('0'); + } + sb.append(hexString); + } + return sb.toString(); + } + + public static String pinRequest(String pin) { + // OLD + if (PIN_REQUEST.equals(pin)) { + return loginRequest(3); + } else { + // 080210c801c202 22 0a 20 0e066c3d1c3a6686edb6b2648ff25fcb3f0bf9cc81deeee9fad1a26073645e17 + // 080210c801c202 22 0a 20 530bb7c7ba06069997285aff6e0106adfb19ab23c18a7422f5f643b35a6467b3 + // -------------------------SHA HASH OF PIN + + int length = pin.length() / 2; + String len1 = GoogleTVRequest.fixMessage(Integer.toHexString(length + 2)); + String len2 = GoogleTVRequest.fixMessage(Integer.toHexString(length)); + return "080210c801c202" + len1 + "0a" + len2 + pin; + } + } + + public static String loginRequest(int messageId) { + String message = ""; + if (messageId == 1) { + // Send app and device name + // 080210c801522d 0a 19 636f6d2e676f6f676c652e616e64726f69642e766964656f73 12 10 + // 73616d73756e6720534d2d4739393855 + // ------------------LEN com.google.android.videos----------------------------LEN samsung SM-G998U + message = "080210c801522d0a19636f6d2e676f6f676c652e616e64726f69642e766964656f73121073616d73756e6720534d2d4739393855"; + } else if (messageId == 2) { + // Unknown but required + // 080210c801a201 0e 0a 04 08031006 0a 04 08031004 1802 + // ---------------LEN---LEN------------LEN + message = "080210c801a2010e0a04080310060a04080310041802"; + } else if (messageId == 3) { + // Trigger PIN OSD + // ---------------LEN---LEN + // 080210c801a201 08 12 04 08031006 1801 + // 080210c801f201 08 0a 04 08031006 1001 + message = "080210c801f201080a04080310061001"; + } else if (messageId == 4) { + // 0a41087e123d0a 08 534d2d4739393855 12 07 73616d73756e67 18 01 22 02 3133 2a + // ---------------LEN--SM-G998U----------LEN--samsung--------- + // 19 636f6d2e676f6f676c652e616e64726f69642e766964656f73 32 + // LEN-com.google.android.videos---------------------------- + // 07 342e33382e3138 + // LEN-4.38.18 + // message = + // "0a41087e123d0a08534d2d4739393855120773616d73756e671801220231332a19636f6d2e676f6f676c652e616e64726f69642e766964656f733207342e33382e3138"; + + // 0a5708fe0412520a 08 534d2d4739393855 12 07 73616d73756e67 18 01 22 02 3133 2a + // -----------------LEN--SM-G998U----------LEN--samsung--------- + // 19 636f6d2e676f6f676c652e616e64726f69642e766964656f73 32 + // LEN-com.google.android.videos--------------------------- + // 1c 342e33392e3538342e3532393538383538332e372d72656c65617365 + // LEN-4.39.584.529588583.7-release + message = "0a5708fe0412520a08534d2d4739393855120773616d73756e671801220231332a19636f6d2e676f6f676c652e616e64726f69642e766964656f73321c342e33392e3538342e3532393538383538332e372d72656c65617365"; + } else if (messageId == 5) { + // Unknown. Sent after "1200" received + message = "1202087e"; + } + return message; + } + + public static String keepAlive(String request) { + // 42 07 08 01 10 e4f1 8d01 + // 4a 02 08 01 + + // 42 08 08 7f 10 b4 908a a819 + // 4a 02 08 7f + + // 42 09 08 8001 10 ed b78a a819 + // 4a 03 08 8001 + + char[] charArray = request.toCharArray(); + StringBuilder sb = new StringBuilder(); + sb.append(request); + sb.setLength(sb.toString().length() - 6); + String st = ""; + do { + int sbLen = sb.toString().length(); + st = "" + charArray[sbLen - 2] + charArray[sbLen - 1]; + if (!DELIMITER_10.equals(st)) { + sb.setLength(sbLen - 2); + } + } while (!DELIMITER_10.equals(st)); + sb.setLength(sb.toString().length() - 2); + + StringBuilder sbReply = new StringBuilder(); + for (int i = 4; i < sb.toString().length(); i++) { + sbReply.append(charArray[i]); + } + return "4a" + fixMessage(Integer.toHexString(sbReply.toString().length() / 2)) + sbReply.toString(); + } + + public static String fixMessage(String tempMsg) { + if (tempMsg.length() % 2 > 0) { + tempMsg = "0" + tempMsg; + } + return tempMsg; + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVUtils.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVUtils.java new file mode 100644 index 0000000000000..7747e9cd3abc9 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVUtils.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.googletv; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.interfaces.RSAPublicKey; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * GoogleTVCommand represents a GoogleTV protocol command + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class GoogleTVUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(GoogleTVUtils.class); + + private static String processMag(final byte[] magnitude) { + final int length = magnitude.length; + if (length != 0) { + final BigInteger bigInteger = new BigInteger(1, magnitude); + final StringBuilder sb = new StringBuilder(); + sb.append("%0"); + sb.append(length + length); + sb.append("x"); + return String.format(sb.toString(), bigInteger); + } + return ""; + } + + private static final byte[] processDigestArray(final byte[] array) { + int n = 0; + int length; + while (true) { + length = array.length; + if (n >= length || array[n] != 0) { + break; + } + ++n; + } + final int n2 = length - n; + final byte[] array2 = new byte[n2]; + System.arraycopy(array, n, array2, 0, n2); + return array2; + } + + public static final byte[] processDigest(byte[] digest, Certificate clientCert, Certificate serverCert) { + final PublicKey clientPublicKey = clientCert.getPublicKey(); + final PublicKey serverPublicKey = serverCert.getPublicKey(); + processMag(digest); + if (clientPublicKey instanceof RSAPublicKey && serverPublicKey instanceof RSAPublicKey) { + final RSAPublicKey clientRSAPublicKey = (RSAPublicKey) clientPublicKey; + final RSAPublicKey serverRSAPublicKey = (RSAPublicKey) serverPublicKey; + try { + final MessageDigest instance = MessageDigest.getInstance("SHA-256"); + final byte[] byteArray1 = clientRSAPublicKey.getModulus().abs().toByteArray(); + final byte[] byteArray2 = clientRSAPublicKey.getPublicExponent().abs().toByteArray(); + final byte[] byteArray3 = serverRSAPublicKey.getModulus().abs().toByteArray(); + final byte[] byteArray4 = serverRSAPublicKey.getPublicExponent().abs().toByteArray(); + final byte[] r1 = processDigestArray(byteArray1); + final byte[] r2 = processDigestArray(byteArray2); + final byte[] r3 = processDigestArray(byteArray3); + final byte[] r4 = processDigestArray(byteArray4); + processMag(r1); + processMag(r2); + processMag(r3); + processMag(r4); + processMag(digest); + instance.update(r1); + instance.update(r2); + instance.update(r3); + instance.update(r4); + instance.update(digest); + digest = instance.digest(); + processMag(digest); + } catch (NoSuchAlgorithmException e) { + LOGGER.warn("NoSuchAlgorithmException Exception", e); + } + } + return digest; + } + + public static String byteArrayToString(byte[] array) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < array.length; i++) { + sb.append((char) (array[i] & 0xFF)); + } + return sb.toString(); + } + + public static String validatePIN(String pin, Certificate clientCert, Certificate serverCert) { + char[] charArray = pin.toCharArray(); + + String s1 = "" + charArray[0] + charArray[1]; + String s2 = "" + charArray[2] + charArray[3]; + String s3 = "" + charArray[4] + charArray[5]; + int si1 = Integer.parseInt(s1, 16); + int si2 = Integer.parseInt(s2, 16); + int si3 = Integer.parseInt(s3, 16); + + byte[] sb123 = new byte[] { (byte) si1, (byte) si2, (byte) si3 }; + byte[] sb23 = new byte[] { (byte) si2, (byte) si3 }; + byte[] digest = processDigest(sb23, clientCert, serverCert); + String digestString = GoogleTVRequest.decodeMessage(byteArrayToString(digest)); + + byte[] validPinB = new byte[] { digest[0], (byte) si2, (byte) si3 }; + String validPin = GoogleTVRequest.decodeMessage(byteArrayToString(validPinB)); + LOGGER.trace("validatePIN {} {} {} {} {} {}", sb123, digest[0], sb23, validPinB, validPin, digestString); + + return digestString; + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVCommand.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVCommand.java new file mode 100644 index 0000000000000..a67f201c81f70 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVCommand.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.shieldtv; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * ShieldTVCommand represents a ShieldTV protocol command + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class ShieldTVCommand { + private String command; + + public ShieldTVCommand(String command) { + this.command = command; + } + + @Override + public String toString() { + return command; + } + + public boolean isEmpty() { + return command.isEmpty(); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConfiguration.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConfiguration.java new file mode 100644 index 0000000000000..3847612625a74 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConfiguration.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.shieldtv; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ShieldTVConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class ShieldTVConfiguration { + + public String ipAddress = ""; + public int port = 8987; + public int reconnect; + public int heartbeat; + public String keystoreFileName = ""; + public String keystorePassword = ""; + public int delay = 0; + public boolean shim; + public boolean shimNewKeys; +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConnectionManager.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConnectionManager.java new file mode 100644 index 0000000000000..3fed339d5b651 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConnectionManager.java @@ -0,0 +1,1219 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.shieldtv; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; +import static org.openhab.binding.androidtv.internal.protocol.shieldtv.ShieldTVConstants.*; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.InterruptedIOException; +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.NoRouteToHostException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.androidtv.internal.AndroidTVHandler; +import org.openhab.binding.androidtv.internal.utils.AndroidTVPKI; +import org.openhab.core.OpenHAB; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ShieldTVConnectionManager} is responsible for handling connections via the shieldtv protocol + * + * Significant portions reused from Lutron binding with permission from Bob A. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class ShieldTVConnectionManager { + private static final int DEFAULT_RECONNECT_SECONDS = 60; + private static final int DEFAULT_HEARTBEAT_SECONDS = 5; + private static final long KEEPALIVE_TIMEOUT_SECONDS = 30; + private static final String DEFAULT_KEYSTORE_PASSWORD = "secret"; + private static final int DEFAULT_PORT = 8987; + + private final Logger logger = LoggerFactory.getLogger(ShieldTVConnectionManager.class); + + private ScheduledExecutorService scheduler; + + private final AndroidTVHandler handler; + private ShieldTVConfiguration config; + + private @NonNullByDefault({}) SSLSocketFactory sslSocketFactory; + private @Nullable SSLSocket sslSocket; + private @Nullable BufferedWriter writer; + private @Nullable BufferedReader reader; + + private @NonNullByDefault({}) SSLServerSocketFactory sslServerSocketFactory; + private @Nullable Socket shimServerSocket; + private @Nullable BufferedWriter shimWriter; + private @Nullable BufferedReader shimReader; + + private @NonNullByDefault({}) ShieldTVMessageParser messageParser; + + private final BlockingQueue sendQueue = new LinkedBlockingQueue<>(); + private final BlockingQueue shimQueue = new LinkedBlockingQueue<>(); + + private @Nullable Future asyncInitializeTask; + private @Nullable Future shimAsyncInitializeTask; + + private @Nullable Thread senderThread; + private @Nullable Thread readerThread; + private @Nullable Thread shimSenderThread; + private @Nullable Thread shimReaderThread; + + private @Nullable ScheduledFuture keepAliveJob; + private @Nullable ScheduledFuture keepAliveReconnectJob; + private @Nullable ScheduledFuture connectRetryJob; + private final Object keepAliveReconnectLock = new Object(); + private final Object connectionLock = new Object(); + private int periodicUpdate; + + private @Nullable ScheduledFuture deviceHealthJob; + private boolean isOnline = true; + + private StringBuffer sbReader = new StringBuffer(); + private StringBuffer sbShimReader = new StringBuffer(); + private String lastMsg = ""; + private String thisMsg = ""; + private boolean inMessage = false; + private String msgType = ""; + + private boolean disposing = false; + private boolean isLoggedIn = false; + private String statusMessage = ""; + + private String hostName = ""; + private String currentApp = ""; + private String deviceId = ""; + private String arch = ""; + + private AndroidTVPKI androidtvPKI = new AndroidTVPKI(); + private byte[] encryptionKey; + + private boolean appDBPopulated = false; + private Map appNameDB = new HashMap<>(); + private Map appURLDB = new HashMap<>(); + + public ShieldTVConnectionManager(AndroidTVHandler handler, ShieldTVConfiguration config) { + messageParser = new ShieldTVMessageParser(this); + this.config = config; + this.handler = handler; + this.scheduler = handler.getScheduler(); + this.encryptionKey = androidtvPKI.generateEncryptionKey(); + initialize(); + } + + public void setHostName(String hostName) { + this.hostName = hostName; + handler.setThingProperty("deviceName", hostName); + } + + public String getHostName() { + return hostName; + } + + public String getThingID() { + return handler.getThingID(); + } + + public void setDeviceID(String deviceId) { + this.deviceId = deviceId; + handler.setThingProperty("deviceID", deviceId); + } + + public String getDeviceID() { + return deviceId; + } + + public void setArch(String arch) { + this.arch = arch; + handler.setThingProperty("architectures", arch); + } + + public String getArch() { + return arch; + } + + public void setCurrentApp(String currentApp) { + this.currentApp = currentApp; + handler.updateChannelState(CHANNEL_APP, new StringType(currentApp)); + + if (this.appDBPopulated) { + String appName = ""; + String appURL = ""; + + if (appNameDB.get(currentApp) != null) { + appName = appNameDB.get(currentApp); + handler.updateChannelState(CHANNEL_APPNAME, new StringType(appName)); + } else { + logger.info("Unknown Android App: {}", currentApp); + handler.updateChannelState(CHANNEL_APPNAME, new StringType("")); + } + + if (appURLDB.get(currentApp) != null) { + appURL = appURLDB.get(currentApp); + handler.updateChannelState(CHANNEL_APPURL, new StringType(appURL)); + } else { + handler.updateChannelState(CHANNEL_APPURL, new StringType("")); + } + } + } + + public String getStatusMessage() { + return statusMessage; + } + + private void setStatus(boolean isLoggedIn) { + if (isLoggedIn) { + setStatus(isLoggedIn, "ONLINE"); + } else { + setStatus(isLoggedIn, "UNKNOWN"); + } + } + + private void setStatus(boolean isLoggedIn, String statusMessage) { + if ((this.isLoggedIn != isLoggedIn) || (!this.statusMessage.equals(statusMessage))) { + this.isLoggedIn = isLoggedIn; + this.statusMessage = statusMessage; + handler.checkThingStatus(); + } + } + + public String getCurrentApp() { + return currentApp; + } + + private void sendPeriodicUpdate() { + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("080b120308cd08"))); // Get Hostname + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08f30712020805"))); // No Reply + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08f10712020800"))); // Get App DB + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08ec0712020806"))); // Get App + } + + public void setLoggedIn(boolean isLoggedIn) { + if (!this.isLoggedIn && isLoggedIn) { + sendPeriodicUpdate(); + } + + if (this.isLoggedIn != isLoggedIn) { + setStatus(isLoggedIn); + } + } + + public boolean getLoggedIn() { + return isLoggedIn; + } + + private boolean servicePing() { + int timeout = 500; + + SocketAddress socketAddress = new InetSocketAddress(config.ipAddress, config.port); + try (Socket socket = new Socket()) { + socket.connect(socketAddress, timeout); + return true; + } catch (ConnectException | SocketTimeoutException | NoRouteToHostException ignored) { + return false; + } catch (IOException ignored) { + // IOException is thrown by automatic close() of the socket. + // This should actually never return a value as we should return true above already + return true; + } + } + + private void checkHealth() { + boolean isOnline; + if (!isLoggedIn) { + isOnline = servicePing(); + } else { + isOnline = true; + } + logger.debug("{} - Device Health - Online: {} - Logged In: {}", handler.getThingID(), isOnline, isLoggedIn); + if (isOnline != this.isOnline) { + this.isOnline = isOnline; + if (isOnline) { + logger.debug("{} - Device is back online. Attempting reconnection.", handler.getThingID()); + reconnect(); + } + } + } + + public void setKeys(String privKey, String cert) { + try { + androidtvPKI.setKeys(privKey, encryptionKey, cert); + androidtvPKI.saveKeyStore(config.keystorePassword, encryptionKey); + } catch (GeneralSecurityException e) { + logger.debug("General security exception", e); + } catch (IOException e) { + logger.debug("IO Exception", e); + } catch (Exception e) { + logger.debug("General Exception", e); + } + } + + public void setAppDB(Map appNameDB, Map appURLDB) { + this.appNameDB = appNameDB; + this.appURLDB = appURLDB; + this.appDBPopulated = true; + logger.debug("{} - App DB Populated", handler.getThingID()); + logger.trace("{} - Handler appNameDB: {} appURLDB: {}", handler.getThingID(), this.appNameDB, this.appURLDB); + handler.updateCDP(CHANNEL_APP, this.appNameDB); + } + + private TrustManager[] defineNoOpTrustManager() { + return new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) { + logger.debug("Assuming client certificate is valid"); + if (chain != null && logger.isTraceEnabled()) { + for (int cert = 0; cert < chain.length; cert++) { + logger.trace("Subject DN: {}", chain[cert].getSubjectX500Principal()); + logger.trace("Issuer DN: {}", chain[cert].getIssuerX500Principal()); + logger.trace("Serial number: {}", chain[cert].getSerialNumber()); + } + } + } + + @Override + public void checkServerTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) { + logger.debug("Assuming server certificate is valid"); + if (chain != null && logger.isTraceEnabled()) { + for (int cert = 0; cert < chain.length; cert++) { + logger.trace("Subject DN: {}", chain[cert].getSubjectX500Principal()); + logger.trace("Issuer DN: {}", chain[cert].getIssuerX500Principal()); + logger.trace("Serial number: {}", chain[cert].getSerialNumber()); + } + } + } + + @Override + public X509Certificate @Nullable [] getAcceptedIssuers() { + return null; + } + } }; + } + + private void initialize() { + SSLContext sslContext; + + String folderName = OpenHAB.getUserDataFolder() + "/androidtv"; + File folder = new File(folderName); + + if (!folder.exists()) { + logger.debug("Creating directory {}", folderName); + folder.mkdirs(); + } + + config.port = (config.port > 0) ? config.port : DEFAULT_PORT; + config.reconnect = (config.reconnect > 0) ? config.reconnect : DEFAULT_RECONNECT_SECONDS; + config.heartbeat = (config.heartbeat > 0) ? config.heartbeat : DEFAULT_HEARTBEAT_SECONDS; + config.delay = (config.delay < 0) ? 0 : config.delay; + config.shim = (config.shim) ? true : false; + config.shimNewKeys = (config.shimNewKeys) ? true : false; + + config.keystoreFileName = (!config.keystoreFileName.equals("")) ? config.keystoreFileName + : folderName + "/shieldtv." + ((config.shim) ? "shim." : "") + handler.getThing().getUID().getId() + + ".keystore"; + config.keystorePassword = (!config.keystorePassword.equals("")) ? config.keystorePassword + : DEFAULT_KEYSTORE_PASSWORD; + + androidtvPKI.setKeystoreFileName(config.keystoreFileName); + androidtvPKI.setAlias("nvidia"); + + deviceHealthJob = scheduler.scheduleWithFixedDelay(this::checkHealth, config.heartbeat, config.heartbeat, + TimeUnit.SECONDS); + + try { + File keystoreFile = new File(config.keystoreFileName); + + if (!keystoreFile.exists() || config.shimNewKeys) { + androidtvPKI.generateNewKeyPair(encryptionKey); + androidtvPKI.saveKeyStore(config.keystorePassword, this.encryptionKey); + } else { + androidtvPKI.loadFromKeyStore(config.keystorePassword, this.encryptionKey); + } + + logger.trace("{} - Initializing SSL Context", handler.getThingID()); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(androidtvPKI.getKeyStore(config.keystorePassword, this.encryptionKey), + config.keystorePassword.toCharArray()); + + TrustManager[] trustManagers = defineNoOpTrustManager(); + + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), trustManagers, null); + + sslSocketFactory = sslContext.getSocketFactory(); + if (!config.shim) { + asyncInitializeTask = scheduler.submit(this::connect); + } else { + shimAsyncInitializeTask = scheduler.submit(this::shimInitialize); + } + } catch (NoSuchAlgorithmException | IOException e) { + setStatus(false, "Error initializing keystore"); + logger.debug("Error initializing keystore", e); + } catch (UnrecoverableKeyException e) { + setStatus(false, "Key unrecoverable with supplied password"); + } catch (GeneralSecurityException e) { + logger.debug("General security exception", e); + } catch (Exception e) { + logger.debug("General exception", e); + } + } + + public void connect() { + synchronized (connectionLock) { + if (isOnline) { + try { + logger.debug("{} - Opening ShieldTV SSL connection to {}:{}", handler.getThingID(), + config.ipAddress, config.port); + SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(config.ipAddress, config.port); + sslSocket.startHandshake(); + writer = new BufferedWriter( + new OutputStreamWriter(sslSocket.getOutputStream(), StandardCharsets.ISO_8859_1)); + reader = new BufferedReader( + new InputStreamReader(sslSocket.getInputStream(), StandardCharsets.ISO_8859_1)); + this.sslSocket = sslSocket; + } catch (UnknownHostException e) { + setStatus(false, "Unknown host"); + return; + } catch (IllegalArgumentException e) { + // port out of valid range + setStatus(false, "Invalid port number"); + return; + } catch (InterruptedIOException e) { + logger.debug("Interrupted while establishing ShieldTV connection"); + Thread.currentThread().interrupt(); + return; + } catch (IOException e) { + setStatus(false, "Error opening ShieldTV SSL connection. Check log."); + logger.info("{} - Error opening ShieldTV SSL connection to {}:{} {}", handler.getThingID(), + config.ipAddress, config.port, e.getMessage()); + disconnect(false); + scheduleConnectRetry(config.reconnect); // Possibly a temporary problem. Try again later. + return; + } + + setStatus(false, "Initializing"); + + Thread readerThread = new Thread(this::readerThreadJob, "ShieldTV reader " + handler.getThingID()); + readerThread.setDaemon(true); + readerThread.start(); + this.readerThread = readerThread; + + Thread senderThread = new Thread(this::senderThreadJob, "ShieldTV sender " + handler.getThingID()); + senderThread.setDaemon(true); + senderThread.start(); + this.senderThread = senderThread; + + if (!config.shim) { + this.periodicUpdate = 20; + logger.debug("{} - Starting ShieldTV keepalive job with interval {}", handler.getThingID(), + config.heartbeat); + keepAliveJob = scheduler.scheduleWithFixedDelay(this::sendKeepAlive, config.heartbeat, + config.heartbeat, TimeUnit.SECONDS); + + String login = ShieldTVRequest.encodeMessage(ShieldTVRequest.loginRequest()); + sendCommand(new ShieldTVCommand(login)); + } + } else { + scheduleConnectRetry(config.reconnect); // Possibly a temporary problem. Try again later. + } + } + } + + public void shimInitialize() { + synchronized (connectionLock) { + AndroidTVPKI shimPKI = new AndroidTVPKI(); + byte[] shimEncryptionKey = shimPKI.generateEncryptionKey(); + SSLContext sslContext; + + try { + shimPKI.generateNewKeyPair(shimEncryptionKey); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(shimPKI.getKeyStore(config.keystorePassword, shimEncryptionKey), + config.keystorePassword.toCharArray()); + TrustManager[] trustManagers = defineNoOpTrustManager(); + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), trustManagers, null); + this.sslServerSocketFactory = sslContext.getServerSocketFactory(); + + logger.debug("{} - Opening ShieldTV shim on port {}", handler.getThingID(), config.port); + ServerSocket sslServerSocket = this.sslServerSocketFactory.createServerSocket(config.port); + + while (true) { + logger.debug("{} - Waiting for shim connection...", handler.getThingID()); + Socket serverSocket = sslServerSocket.accept(); + disconnect(false); + connect(); + SSLSession session = ((SSLSocket) serverSocket).getSession(); + Certificate[] cchain2 = session.getLocalCertificates(); + for (int i = 0; i < cchain2.length; i++) { + logger.trace("Connection from: {}", ((X509Certificate) cchain2[i]).getSubjectX500Principal()); + } + + logger.trace("Peer host is {}", session.getPeerHost()); + logger.trace("Cipher is {}", session.getCipherSuite()); + logger.trace("Protocol is {}", session.getProtocol()); + logger.trace("ID is {}", new BigInteger(session.getId())); + logger.trace("Session created in {}", session.getCreationTime()); + logger.trace("Session accessed in {}", session.getLastAccessedTime()); + + shimWriter = new BufferedWriter( + new OutputStreamWriter(serverSocket.getOutputStream(), StandardCharsets.ISO_8859_1)); + shimReader = new BufferedReader( + new InputStreamReader(serverSocket.getInputStream(), StandardCharsets.ISO_8859_1)); + this.shimServerSocket = serverSocket; + + Thread readerThread = new Thread(this::shimReaderThreadJob, + "ShieldTV shim reader " + handler.getThingID()); + readerThread.setDaemon(true); + readerThread.start(); + this.shimReaderThread = readerThread; + + Thread senderThread = new Thread(this::shimSenderThreadJob, + "ShieldTV shim sender" + handler.getThingID()); + senderThread.setDaemon(true); + senderThread.start(); + this.shimSenderThread = senderThread; + } + } catch (Exception e) { + logger.trace("Shim initalization exception", e); + return; + } + } + } + + private void scheduleConnectRetry(long waitSeconds) { + logger.trace("{} - Scheduling ShieldTV connection retry in {} seconds", handler.getThingID(), waitSeconds); + connectRetryJob = scheduler.schedule(this::connect, waitSeconds, TimeUnit.SECONDS); + } + + /** + * Disconnect from bridge, cancel retry and keepalive jobs, stop reader and writer threads, and + * clean up. + * + * @param interruptAll Set if reconnect task should be interrupted if running. Should be false when calling from + * connect or reconnect, and true when calling from dispose. + */ + private void disconnect(boolean interruptAll) { + synchronized (connectionLock) { + logger.debug("{} - Disconnecting ShieldTV", handler.getThingID()); + + this.isLoggedIn = false; + + ScheduledFuture connectRetryJob = this.connectRetryJob; + if (connectRetryJob != null) { + connectRetryJob.cancel(true); + } + ScheduledFuture keepAliveJob = this.keepAliveJob; + if (keepAliveJob != null) { + keepAliveJob.cancel(true); + } + + reconnectTaskCancel(interruptAll); // May be called from keepAliveReconnectJob thread + + Thread senderThread = this.senderThread; + if (senderThread != null && senderThread.isAlive()) { + senderThread.interrupt(); + } + + Thread readerThread = this.readerThread; + if (readerThread != null && readerThread.isAlive()) { + readerThread.interrupt(); + } + + Thread shimSenderThread = this.shimSenderThread; + if (shimSenderThread != null && shimSenderThread.isAlive()) { + shimSenderThread.interrupt(); + } + + Thread shimReaderThread = this.shimReaderThread; + if (shimReaderThread != null && shimReaderThread.isAlive()) { + shimReaderThread.interrupt(); + } + + SSLSocket sslSocket = this.sslSocket; + if (sslSocket != null) { + try { + sslSocket.close(); + } catch (IOException e) { + logger.debug("Error closing ShieldTV SSL socket: {}", e.getMessage()); + } + this.sslSocket = null; + } + BufferedReader reader = this.reader; + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + logger.debug("Error closing reader: {}", e.getMessage()); + } + } + BufferedWriter writer = this.writer; + if (writer != null) { + try { + writer.close(); + } catch (IOException e) { + logger.debug("Error closing writer: {}", e.getMessage()); + } + } + + Socket shimServerSocket = this.shimServerSocket; + if (shimServerSocket != null) { + try { + shimServerSocket.close(); + } catch (IOException e) { + logger.debug("Error closing ShieldTV SSL socket: {}", e.getMessage()); + } + this.shimServerSocket = null; + } + BufferedReader shimReader = this.shimReader; + if (shimReader != null) { + try { + shimReader.close(); + } catch (IOException e) { + logger.debug("Error closing shimReader: {}", e.getMessage()); + } + } + BufferedWriter shimWriter = this.shimWriter; + if (shimWriter != null) { + try { + shimWriter.close(); + } catch (IOException e) { + logger.debug("Error closing shimWriter: {}", e.getMessage()); + } + } + } + } + + private void reconnect() { + synchronized (connectionLock) { + if (!this.disposing) { + logger.debug("{} - Attempting to reconnect to the ShieldTV", handler.getThingID()); + setStatus(false, "reconnecting"); + disconnect(false); + connect(); + } + } + } + + /** + * Method executed by the message sender thread (senderThread) + */ + private void senderThreadJob() { + logger.debug("{} - Command sender thread started", handler.getThingID()); + try { + while (!Thread.currentThread().isInterrupted() && writer != null) { + ShieldTVCommand command = sendQueue.take(); + + try { + BufferedWriter writer = this.writer; + if (writer != null) { + logger.trace("{} - Raw ShieldTV command decodes as: {}", handler.getThingID(), + ShieldTVRequest.decodeMessage(command.toString())); + writer.write(command.toString()); + writer.flush(); + } + } catch (InterruptedIOException e) { + logger.debug("Interrupted while sending to ShieldTV"); + setStatus(false, "Interrupted"); + break; // exit loop and terminate thread + } catch (IOException e) { + logger.warn("{} - Communication error, will try to reconnect ShieldTV. Error: {}", + handler.getThingID(), e.getMessage()); + setStatus(false, "Communication error, will try to reconnect"); + sendQueue.add(command); // Requeue command + this.isLoggedIn = false; + reconnect(); + break; // reconnect() will start a new thread; terminate this one + } + if (config.delay > 0) { + Thread.sleep(config.delay); // introduce delay to throttle send rate + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + logger.debug("{} - Command sender thread exiting", handler.getThingID()); + } + } + + private void shimSenderThreadJob() { + logger.debug("Shim sender thread started"); + try { + while (!Thread.currentThread().isInterrupted() && shimWriter != null) { + ShieldTVCommand command = shimQueue.take(); + + try { + BufferedWriter writer = this.shimWriter; + if (writer != null) { + logger.trace("Shim received from shield: {}", + ShieldTVRequest.decodeMessage(command.toString())); + writer.write(command.toString()); + writer.flush(); + } + } catch (InterruptedIOException e) { + logger.debug("Shim interrupted while sending."); + break; // exit loop and terminate thread + } catch (IOException e) { + logger.warn("Shim communication error. Error: {}", e.getMessage()); + break; // reconnect() will start a new thread; terminate this one + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + logger.debug("Command sender thread exiting"); + } + } + + private void flushReader() { + if (!inMessage && (sbReader.length() > 0)) { + sbReader.setLength(sbReader.length() - 2); + messageParser.handleMessage(sbReader.toString()); + if (config.shim) { + sendShim(new ShieldTVCommand(ShieldTVRequest.encodeMessage(sbReader.toString()))); + } + sbReader.setLength(0); + sbReader.append(lastMsg); + } + sbReader.append(thisMsg); + lastMsg = thisMsg; + } + + private void finishReaderMessage() { + sbReader.append(thisMsg); + lastMsg = ""; + inMessage = false; + messageParser.handleMessage(sbReader.toString()); + if (config.shim) { + sendShim(new ShieldTVCommand(ShieldTVRequest.encodeMessage(sbReader.toString()))); + } + sbReader.setLength(0); + } + + private String fixMessage(String tempMsg) { + if (tempMsg.length() % 2 > 0) { + tempMsg = "0" + tempMsg; + } + return tempMsg; + } + + /** + * Method executed by the message reader thread (readerThread) + */ + private void readerThreadJob() { + logger.debug("{} - Message reader thread started", handler.getThingID()); + try { + BufferedReader reader = this.reader; + while (!Thread.interrupted() && reader != null) { + thisMsg = fixMessage(Integer.toHexString(reader.read())); + if (HARD_DROP.equals(thisMsg)) { + // Shield has crashed the connection. Disconnect hard. + logger.debug("{} - readerThreadJob received ffffffff. Disconnecting hard.", handler.getThingID()); + this.isLoggedIn = false; + reconnect(); + break; + } + if (DELIMITER_08.equals(lastMsg) && !inMessage) { + flushReader(); + inMessage = true; + msgType = thisMsg; + } else if (DELIMITER_18.equals(lastMsg) && thisMsg.equals(msgType) && inMessage) { + if (!msgType.startsWith(DELIMITER_0)) { + sbReader.append(thisMsg); + thisMsg = fixMessage(Integer.toHexString(reader.read())); + } + finishReaderMessage(); + } else if (DELIMITER_00.equals(msgType) && (sbReader.toString().length() == 16)) { + // keepalive messages don't have delimiters but are always 18 in length + finishReaderMessage(); + } else { + sbReader.append(thisMsg); + lastMsg = thisMsg; + } + } + } catch (InterruptedIOException e) { + logger.debug("Interrupted while reading"); + setStatus(false, "Interrupted"); + } catch (IOException e) { + logger.debug("I/O error while reading from stream: {}", e.getMessage()); + setStatus(false, "I/O Error"); + } catch (RuntimeException e) { + logger.warn("Runtime exception in reader thread", e); + setStatus(false, "Runtime exception"); + } finally { + logger.debug("{} - Message reader thread exiting", handler.getThingID()); + } + } + + private void shimReaderThreadJob() { + logger.debug("Shim reader thread started"); + String thisShimMsg = ""; + int thisShimRawMsg = 0; + int payloadRemain = 0; + int payloadBlock = 0; + String thisShimMsgType = ""; + boolean inShimMessage = false; + try { + BufferedReader reader = this.shimReader; + while (!Thread.interrupted() && reader != null) { + thisShimRawMsg = reader.read(); + thisShimMsg = fixMessage(Integer.toHexString(thisShimRawMsg)); + if (HARD_DROP.equals(thisShimMsg)) { + disconnect(false); + break; + } + if (!inShimMessage) { + // Beginning of payload + sbShimReader.setLength(0); + sbShimReader.append(thisShimMsg); + inShimMessage = true; + payloadBlock++; + } else if ((payloadBlock == 1) && (DELIMITER_00.equals(thisShimMsg))) { + sbShimReader.append(thisShimMsg); + payloadRemain = 8; + thisShimMsgType = thisShimMsg; + while (payloadRemain > 1) { + thisShimMsg = fixMessage(Integer.toHexString(reader.read())); + sbShimReader.append(thisShimMsg); + payloadRemain--; + payloadBlock++; + } + payloadRemain--; + payloadBlock++; + } else if ((payloadBlock == 1) + && (thisShimMsg.startsWith(DELIMITER_F1) || thisShimMsg.startsWith(DELIMITER_F3))) { + sbShimReader.append(thisShimMsg); + payloadRemain = 6; + thisShimMsgType = thisShimMsg; + while (payloadRemain > 1) { + thisShimMsg = fixMessage(Integer.toHexString(reader.read())); + sbShimReader.append(thisShimMsg); + payloadRemain--; + payloadBlock++; + } + payloadRemain--; + payloadBlock++; + } else if (payloadBlock == 1) { + thisShimMsgType = thisShimMsg; + sbShimReader.append(thisShimMsg); + payloadBlock++; + } else if (payloadBlock == 2) { + sbShimReader.append(thisShimMsg); + payloadBlock++; + } else if (payloadBlock == 3) { + // Length of remainder of packet + payloadRemain = thisShimRawMsg; + sbShimReader.append(thisShimMsg); + payloadBlock++; + } else if (payloadBlock == 4) { + sbShimReader.append(thisShimMsg); + logger.trace("PB4 SSR {} TSMT {} TSM {} PR {}", sbShimReader.toString(), thisShimMsgType, + thisShimMsg, payloadRemain); + if (DELIMITER_E9.equals(thisShimMsgType) || DELIMITER_F0.equals(thisShimMsgType) + || DELIMITER_EC.equals(thisShimMsgType)) { + payloadRemain = thisShimRawMsg + 1; + } + while (payloadRemain > 1) { + thisShimMsg = fixMessage(Integer.toHexString(reader.read())); + sbShimReader.append(thisShimMsg); + payloadRemain--; + payloadBlock++; + } + payloadRemain--; + payloadBlock++; + } + + if ((payloadBlock > 5) && (payloadRemain == 0)) { + logger.trace("Shim sending to shield: {}", sbShimReader.toString()); + sendQueue.add(new ShieldTVCommand(ShieldTVRequest.encodeMessage(sbShimReader.toString()))); + inShimMessage = false; + payloadBlock = 0; + payloadRemain = 0; + sbShimReader.setLength(0); + } + } + } catch (InterruptedIOException e) { + logger.debug("Interrupted while reading"); + setStatus(false, "Interrupted"); + } catch (IOException e) { + logger.debug("I/O error while reading from stream: {}", e.getMessage()); + setStatus(false, "I/O Error"); + } catch (RuntimeException e) { + logger.warn("Runtime exception in reader thread", e); + setStatus(false, "Runtime exception"); + } finally { + logger.debug("Message reader thread exiting"); + } + } + + private void sendKeepAlive() { + logger.trace("{} - Sending ShieldTV keepalive query", handler.getThingID()); + String keepalive = ShieldTVRequest.encodeMessage(ShieldTVRequest.keepAlive()); + sendCommand(new ShieldTVCommand(keepalive)); + if (isLoggedIn) { + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08ec0712020806"))); // Get App + if (this.periodicUpdate <= 1) { + sendPeriodicUpdate(); + this.periodicUpdate = 20; + } else { + periodicUpdate--; + } + } + reconnectTaskSchedule(); + } + + /** + * Schedules the reconnect task keepAliveReconnectJob to execute in KEEPALIVE_TIMEOUT_SECONDS. This should + * be + * cancelled by calling reconnectTaskCancel() if a valid response is received from the bridge. + */ + private void reconnectTaskSchedule() { + synchronized (keepAliveReconnectLock) { + keepAliveReconnectJob = scheduler.schedule(this::keepAliveTimeoutExpired, KEEPALIVE_TIMEOUT_SECONDS, + TimeUnit.SECONDS); + } + } + + /** + * Cancels the reconnect task keepAliveReconnectJob. + */ + private void reconnectTaskCancel(boolean interrupt) { + synchronized (keepAliveReconnectLock) { + ScheduledFuture keepAliveReconnectJob = this.keepAliveReconnectJob; + if (keepAliveReconnectJob != null) { + logger.trace("{} - Canceling ShieldTV scheduled reconnect job.", handler.getThingID()); + keepAliveReconnectJob.cancel(interrupt); + this.keepAliveReconnectJob = null; + } + } + } + + /** + * Executed by keepAliveReconnectJob if it is not cancelled by the LEAP message parser calling + * validMessageReceived() which in turn calls reconnectTaskCancel(). + */ + private void keepAliveTimeoutExpired() { + logger.debug("{} - ShieldTV keepalive response timeout expired. Initiating reconnect.", handler.getThingID()); + reconnect(); + } + + public void validMessageReceived() { + reconnectTaskCancel(true); // Got a good message, so cancel reconnect task. + } + + public void sendCommand(ShieldTVCommand command) { + if ((!config.shim) && (!command.isEmpty())) { + sendQueue.add(command); + } + } + + public void sendShim(ShieldTVCommand command) { + if (!command.isEmpty()) { + shimQueue.add(command); + } + } + + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("{} - Command received: {}", handler.getThingID(), channelUID.getId()); + + if (CHANNEL_KEYPRESS.equals(channelUID.getId())) { + if (command instanceof StringType) { + switch (command.toString()) { + case "KEY_UP": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202ce01"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202ce01"))); + break; + case "KEY_DOWN": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d801"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d801"))); + break; + case "KEY_RIGHT": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d401"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d401"))); + break; + case "KEY_LEFT": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d201"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d201"))); + break; + case "KEY_ENTER": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202c205"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202c205"))); + break; + case "KEY_HOME": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d802"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d802"))); + break; + case "KEY_BACK": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202bc02"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202bc02"))); + break; + case "KEY_MENU": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a280132029602"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a280232029602"))); + break; + case "KEY_PLAYPAUSE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202F604"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202F604"))); + break; + case "KEY_REWIND": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202D002"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202D002"))); + break; + case "KEY_FORWARD": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202A003"))); + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202A003"))); + break; + case "KEY_UP_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202ce01"))); + break; + case "KEY_DOWN_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d801"))); + break; + case "KEY_RIGHT_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d401"))); + break; + case "KEY_LEFT_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d201"))); + break; + case "KEY_ENTER_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202c205"))); + break; + case "KEY_HOME_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202d802"))); + break; + case "KEY_BACK_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202bc02"))); + break; + case "KEY_MENU_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a280132029602"))); + break; + case "KEY_PLAYPAUSE_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202F604"))); + break; + case "KEY_REWIND_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202D002"))); + break; + case "KEY_FORWARD_PRESS": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28013202A003"))); + break; + case "KEY_UP_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202ce01"))); + break; + case "KEY_DOWN_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d801"))); + break; + case "KEY_RIGHT_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d401"))); + break; + case "KEY_LEFT_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d201"))); + break; + case "KEY_ENTER_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202c205"))); + break; + case "KEY_HOME_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202d802"))); + break; + case "KEY_BACK_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202bc02"))); + break; + case "KEY_MENU_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a280232029602"))); + break; + case "KEY_PLAYPAUSE_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202F604"))); + break; + case "KEY_REWIND_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202D002"))); + break; + case "KEY_FORWARD_RELEASE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08e907120c08141001200a28023202A003"))); + break; + case "KEY_POWER": + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08e907120808141005201e401e"))); + break; + case "KEY_POWERON": + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08e907120808141005201e4010"))); + break; + case "KEY_GOOGLE": + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08e907120808141005201e401f"))); + break; + case "KEY_VOLUP": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08f007120c08031208080110031a020102"))); + break; + case "KEY_VOLDOWN": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08f007120c08031208080110011a020102"))); + break; + case "KEY_MUTE": + sendCommand(new ShieldTVCommand( + ShieldTVRequest.encodeMessage("08f007120c08031208080110021a020102"))); + break; + case "KEY_SUBMIT": + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08e9071209081410012001320138"))); + break; + } + if (command.toString().length() == 5) { + // Account for KEY_(ASCII Character) + String keyPress = "08ec07120708011201" + + ShieldTVRequest.decodeMessage(new String("" + command.toString().charAt(4))) + "1801"; + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage(keyPress))); + } else { + logger.trace("Unknown Keypress: {}", command.toString()); + } + } + } else if (CHANNEL_PINCODE.equals(channelUID.getId())) { + if (command instanceof StringType) { + if (!isLoggedIn) { + // Do PIN for shieldtv protocol + logger.debug("{} - ShieldTV PIN Process Started", handler.getThingID()); + String pin = ShieldTVRequest.pinRequest(command.toString()); + String message = ShieldTVRequest.encodeMessage(pin); + sendCommand(new ShieldTVCommand(message)); + } + } + } else if (CHANNEL_DEBUG.equals(channelUID.getId())) { + if (command instanceof StringType) { + if (command.toString().startsWith("RAW", 9)) { + String newCommand = command.toString().substring(13); + String message = ShieldTVRequest.encodeMessage(newCommand); + if (logger.isTraceEnabled()) { + logger.trace("Raw Message Decodes as: {}", ShieldTVRequest.decodeMessage(message)); + } + sendCommand(new ShieldTVCommand(message)); + } else if (command.toString().startsWith("MSG", 9)) { + String newCommand = command.toString().substring(13); + messageParser.handleMessage(newCommand); + } + } + } else if (CHANNEL_APP.equals(channelUID.getId())) { + if (command instanceof StringType) { + String message = ShieldTVRequest.encodeMessage(ShieldTVRequest.startApp(command.toString())); + sendCommand(new ShieldTVCommand(message)); + } + } else if (CHANNEL_KEYBOARD.equals(channelUID.getId())) { + if (command instanceof StringType) { + String entry = ShieldTVRequest.keyboardEntry(command.toString()); + logger.trace("Keyboard Entry {}", entry); + String message = ShieldTVRequest.encodeMessage(entry); + sendCommand(new ShieldTVCommand(message)); + sendCommand(new ShieldTVCommand(ShieldTVRequest.encodeMessage("08e9071209081410012001320138"))); + } + } + } + + public void dispose() { + this.disposing = true; + + Future asyncInitializeTask = this.asyncInitializeTask; + if (asyncInitializeTask != null) { + asyncInitializeTask.cancel(true); // Interrupt async init task if it isn't done yet + } + Future shimAsyncInitializeTask = this.shimAsyncInitializeTask; + if (shimAsyncInitializeTask != null) { + shimAsyncInitializeTask.cancel(true); // Interrupt async init task if it isn't done yet + } + ScheduledFuture deviceHealthJob = this.deviceHealthJob; + if (deviceHealthJob != null) { + deviceHealthJob.cancel(true); + } + disconnect(true); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConstants.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConstants.java new file mode 100644 index 0000000000000..59615822b4bf0 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConstants.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.shieldtv; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ShieldTVConstants} class defines common constants, which are + * used across the shieldtv protocol. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class ShieldTVConstants { + + // List of all static String literals + public static final String DELIMITER_0 = "0"; + public static final String DELIMITER_00 = "00"; + public static final String DELIMITER_08 = "08"; + public static final String DELIMITER_0A = "0a"; + public static final String DELIMITER_12 = "12"; + public static final String DELIMITER_18 = "18"; + public static final String DELIMITER_22 = "22"; + public static final String DELIMITER_2A = "2a"; + public static final String DELIMITER_E9 = "e9"; + public static final String DELIMITER_EC = "ec"; + public static final String DELIMITER_F0 = "f0"; + public static final String DELIMITER_F1 = "f1"; + public static final String DELIMITER_F3 = "f3"; + + public static final String HARD_DROP = "ffffffff"; + + public static final String APP_START_SUCCESS = "08f1071202080318f107"; + public static final String APP_START_FAILED = "08f107120608031202080118f107"; + public static final String KEEPALIVE_REPLY = "080028fae0a6c0d130"; + public static final String TIMEOUT = "080a121108b510120c0804120854696d65206f7574180a"; + + public static final String MESSAGE_LOWPRIV = "080a12"; + public static final String MESSAGE_HOSTNAME = "080b12"; + public static final String MESSAGE_APPDB = "08f10712"; + public static final String MESSAGE_GOOD_COMMAND = "08f30712"; + public static final String MESSAGE_PINSTART = "0308cf08"; + public static final String MESSAGE_CERT_COMING = "20"; + public static final String MESSAGE_SUCCESS = "08f007"; + public static final String MESSAGE_APP_SUCCESS = "08ec07"; + public static final String MESSAGE_APP_GET_SUCCESS = "0803"; + public static final String MESSAGE_APP_CURRENT = "0807"; + public static final String MESSAGE_SHORTNAME = "08e807"; + public static final String MESSAGE_CERT = "08b510"; + public static final String MESSAGE_CERT_PAYLOAD = "0753756363657373"; +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVMessageParser.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVMessageParser.java new file mode 100644 index 0000000000000..2707c22a27ef6 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVMessageParser.java @@ -0,0 +1,445 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.shieldtv; + +import static org.openhab.binding.androidtv.internal.protocol.shieldtv.ShieldTVConstants.*; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import javax.xml.bind.DatatypeConverter; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class responsible for parsing incoming ShieldTV messages. Calls back to an object implementing the + * ShieldTVMessageParserCallbacks interface. + * + * Adapted from Lutron Leap binding + * + * @author Ben Rosenblum - Initial contribution + */ + +@NonNullByDefault +public class ShieldTVMessageParser { + private final Logger logger = LoggerFactory.getLogger(ShieldTVMessageParser.class); + + private final ShieldTVConnectionManager callback; + + public ShieldTVMessageParser(ShieldTVConnectionManager callback) { + this.callback = callback; + } + + public void handleMessage(String msg) { + if (msg.trim().isEmpty()) { + return; // Ignore empty lines + } + + String thingId = callback.getThingID(); + String hostName = callback.getHostName(); + logger.trace("{} - Received ShieldTV message from: {} - Message: {}", thingId, hostName, msg); + + callback.validMessageReceived(); + + char[] charArray = msg.toCharArray(); + + try { + // All lengths are little endian when larger than 0xff + if (msg.startsWith(MESSAGE_LOWPRIV) && msg.startsWith(MESSAGE_SHORTNAME, 8)) { + // Pre-login Hostname of Shield Replied + // 080a 12 1408e807 12 0f08e807 12 LEN Hostname 18d7fd04 180a + // 080a 12 1d08e807 12 180801 12 LEN Hostname 18d7fd04 180a + // 080a 12 2208e807 12 1d08e807 12 LEN Hostname 18d7fd04 180a + // Each chunk ends in 12 + // 4th chunk represent length of the name. + // 5th chunk is the name + int chunk = 0; + int i = 0; + String st = ""; + StringBuilder hostname = new StringBuilder(); + while (chunk < 3) { + st = "" + charArray[i] + "" + charArray[i + 1]; + if (DELIMITER_12.equals(st)) { + chunk++; + } + i += 2; + } + st = "" + charArray[i] + "" + charArray[i + 1]; + i += 2; + int length = Integer.parseInt(st, 16) * 2; + int current = i; + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + hostname.append(st); + } + logger.trace("{} - Shield Hostname: {} {}", thingId, hostname, length); + String encHostname = ShieldTVRequest.encodeMessage(hostname.toString()); + logger.debug("{} - Shield Hostname Encoded: {}", thingId, encHostname); + callback.setHostName(encHostname); + } else if (msg.startsWith(MESSAGE_HOSTNAME)) { + // Longer hostname reply + // 080b 12 5b08b510 12 TOTALLEN? 0a LEN Hostname 12 LEN IPADDR Padding? 22 LEN DeviceID 2a LEN arm64-v8a + // 2a LEN armeabi-v7a 2a LEN armeabi 180b + // It's possible for there to be more or less of the arm lists + logger.trace("{} - Longer Hostname Reply", thingId); + + int i = 20; + int length; + int current; + + // Hostname + String st = "" + charArray[i] + "" + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + + StringBuilder hostname = new StringBuilder(); + current = i; + + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + hostname.append(st); + } + + i += 2; // 12 + + // ipAddress + st = "" + charArray[i] + "" + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + + StringBuilder ipAddress = new StringBuilder(); + current = i; + + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + ipAddress.append(st); + } + + st = "" + charArray[i] + "" + charArray[i + 1]; + while (!DELIMITER_22.equals(st)) { + i += 2; + st = "" + charArray[i] + "" + charArray[i + 1]; + } + + i += 2; // 22 + + // deviceId + + st = "" + charArray[i] + "" + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + + StringBuilder deviceId = new StringBuilder(); + current = i; + + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + deviceId.append(st); + } + + // architectures + st = "" + charArray[i] + "" + charArray[i + 1]; + StringBuilder arch = new StringBuilder(); + while (DELIMITER_2A.equals(st)) { + i += 2; + st = "" + charArray[i] + "" + charArray[i + 1]; + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + arch.append(st); + } + st = "" + charArray[i] + "" + charArray[i + 1]; + if (DELIMITER_2A.equals(st)) { + arch.append("2c"); + } + } + + String encHostname = ShieldTVRequest.encodeMessage(hostname.toString()); + String encIpAddress = ShieldTVRequest.encodeMessage(ipAddress.toString()); + String encDeviceId = ShieldTVRequest.encodeMessage(deviceId.toString()); + String encArch = ShieldTVRequest.encodeMessage(arch.toString()); + logger.debug("{} - Hostname: {} - ipAddress: {} - deviceId: {} - arch: {}", thingId, encHostname, + encIpAddress, encDeviceId, encArch); + callback.setHostName(encHostname); + callback.setDeviceID(encDeviceId); + callback.setArch(encArch); + } else if (APP_START_SUCCESS.equals(msg)) { + // App successfully started + logger.debug("{} - App started successfully", thingId); + } else if (APP_START_FAILED.equals(msg)) { + // App failed to start + logger.debug("{} - App failed to start", thingId); + } else if (msg.startsWith(MESSAGE_APPDB) && msg.startsWith(DELIMITER_0A, 18)) { + // Individual update? + // 08f10712 5808061254 0a LEN app.name 12 LEN app.real.name 22 LEN URL 2801 300118f107 + logger.info("{} - Individual App Update - Please Report This: {}", thingId, msg); + } else if (msg.startsWith(MESSAGE_APPDB) && (msg.length() > 30)) { + // Massive dump of currently installed apps + // 08f10712 d81f080112 d31f0a540a LEN app.name 12 LEN app.real.name 22 LEN URL 2801 30010a650a LEN + Map appNameDB = new HashMap<>(); + Map appURLDB = new HashMap<>(); + int appCount = 0; + int i = 18; + String st = ""; + int length; + int current; + StringBuilder appSBPrepend = new StringBuilder(); + StringBuilder appSBDN = new StringBuilder(); + + // Load default apps that don't get sent in payload + + appNameDB.put("com.google.android.tvlauncher", "Android TV Home"); + appURLDB.put("com.google.android.tvlauncher", ""); + + appNameDB.put("com.google.android.katniss", "Google app for Android TV"); + appURLDB.put("com.google.android.katniss", ""); + + appNameDB.put("com.google.android.katnisspx", "Google app for Android TV (Pictures)"); + appURLDB.put("com.google.android.katnisspx", ""); + + appNameDB.put("com.google.android.backdrop", "Backdrop Daydream"); + appURLDB.put("com.google.android.backdrop", ""); + + // Packet will end with 300118f107 after last entry + + while (i < msg.length() - 10) { + StringBuilder appSBName = new StringBuilder(); + StringBuilder appSBURL = new StringBuilder(); + + // There are instances such as plex where multiple apps are sent as part of the same payload + // This is identified when 12 is the beginning of the set + + st = "" + charArray[i] + "" + charArray[i + 1]; + + if (!DELIMITER_12.equals(st)) { + appSBPrepend = new StringBuilder(); + appSBDN = new StringBuilder(); + + appCount++; + + // App Prepend + // Usually 10 in length but can be longer or shorter so look for 0a twice + do { + st = "" + charArray[i] + "" + charArray[i + 1]; + appSBPrepend.append(st); + i += 2; + } while (!DELIMITER_0A.equals(st)); + do { + st = "" + charArray[i] + "" + charArray[i + 1]; + appSBPrepend.append(st); + i += 2; + } while (!DELIMITER_0A.equals(st)); + st = "" + charArray[i] + "" + charArray[i + 1]; + + // Look for a third 0a, but only if 12 is not down the line + // If 12 is exactly 20 away from 0a that means that the DN was actually 10 long + String st2 = "" + charArray[i + 22] + "" + charArray[i + 23]; + if (DELIMITER_0A.equals(st.toString()) && !DELIMITER_12.equals(st2)) { + appSBPrepend.append(st); + i += 2; + st = "" + charArray[i] + "" + charArray[i + 1]; + } + + // app DN + length = Integer.parseInt(st, 16) * 2; + i += 2; + current = i; + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + appSBDN.append(st); + } + } else { + logger.trace("Second Entry"); + } + + // App Name + + i += 2; // 12 delimiter + st = "" + charArray[i] + "" + charArray[i + 1]; + i += 2; + length = Integer.parseInt(st, 16) * 2; + current = i; + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + appSBName.append(st); + } + + // There are times where there is padding here for no reason beyond the specified length. + // Proceed forward until we get to the 22 delimiter + + st = "" + charArray[i] + "" + charArray[i + 1]; + while (!DELIMITER_22.equals(st)) { + i += 2; + st = "" + charArray[i] + "" + charArray[i + 1]; + } + + // App URL + i += 2; // 22 delimiter + st = "" + charArray[i] + "" + charArray[i + 1]; + i += 2; + length = Integer.parseInt(st, 16) * 2; + current = i; + for (; i < current + length; i = i + 2) { + st = "" + charArray[i] + "" + charArray[i + 1]; + appSBURL.append(st); + } + st = "" + charArray[i] + "" + charArray[i + 1]; + if (!DELIMITER_12.equals(st)) { + i += 4; // terminates 2801 + } + String appPrepend = appSBPrepend.toString(); + String appDN = ShieldTVRequest.encodeMessage(appSBDN.toString()); + String appName = ShieldTVRequest.encodeMessage(appSBName.toString()); + String appURL = ShieldTVRequest.encodeMessage(appSBURL.toString()); + logger.debug("{} - AppPrepend: {} AppDN: {} AppName: {} AppURL: {}", thingId, appPrepend, appDN, + appName, appURL); + appNameDB.put(appDN, appName); + appURLDB.put(appDN, appURL); + } + if (appCount > 0) { + Map sortedAppNameDB = new LinkedHashMap<>(); + List valueList = new ArrayList<>(); + for (Map.Entry entry : appNameDB.entrySet()) { + valueList.add(entry.getValue()); + } + Collections.sort(valueList); + for (String str : valueList) { + for (Entry entry : appNameDB.entrySet()) { + if (entry.getValue().equals(str)) { + sortedAppNameDB.put(entry.getKey(), str); + } + } + } + + logger.trace("{} - MP appNameDB: {} sortedAppNameDB: {} appURLDB: {}", thingId, + appNameDB.toString(), sortedAppNameDB.toString(), appURLDB.toString()); + callback.setAppDB(sortedAppNameDB, appURLDB); + } else { + logger.warn("{} - MP empty msg: {} appDB appNameDB: {} appURLDB: {}", thingId, msg, + appNameDB.toString(), appURLDB.toString()); + } + } else if (msg.startsWith(MESSAGE_GOOD_COMMAND)) { + // This has something to do with successful command response, maybe. + } else if (KEEPALIVE_REPLY.equals(msg)) { + // Keepalive Reply + } else if (msg.startsWith(MESSAGE_LOWPRIV) && msg.startsWith(MESSAGE_PINSTART, 6)) { + // 080a 12 0308cf08 180a + logger.debug("PIN Process Started"); + } else if (msg.startsWith(MESSAGE_CERT_COMING) && msg.length() == 6) { + // This seems to be 20**** when observed. It is unclear what this does. + // This seems to send immediately before the certificate reply and as a reply to the pin being sent + } else if (msg.startsWith(MESSAGE_SUCCESS)) { + // Successful command received + // 08f007 12 0c 0804 12 08 0a0608 01100c200f 18f007 - GOOD LOGIN + // 08f007 12 LEN 0804 12 LEN 0a0608 01100c200f 18f007 + // + // 08f00712 0c 0804 12 08 0a0608 01100e200f 18f007 KEY_VOLDOWN + // 08f00712 0c 0804 12 08 0a0608 01100f200f 18f007 KEY_VOLUP + // 08f00712 0c 0804 12 08 0a0608 01200f2801 18f007 KEY_MUTE + logger.info("{} - Login Successful to {}", thingId, callback.getHostName()); + callback.setLoggedIn(true); + } else if (TIMEOUT.equals(msg)) { + // Timeout + // 080a 12 1108b510 12 0c0804 12 08 54696d65206f7574 180a + // 080a 12 1108b510 12 0c0804 12 LEN Timeout 180a + logger.debug("{} - Timeout {}", thingId, msg); + } else if (msg.startsWith(MESSAGE_APP_SUCCESS) && msg.startsWith(MESSAGE_APP_GET_SUCCESS, 10)) { + // Get current app command successful. Usually paired with 0807 reply below. + } else if (msg.startsWith(MESSAGE_APP_SUCCESS) && msg.startsWith(MESSAGE_APP_CURRENT, 10)) { + // Current App + // 08ec07 12 2a0807 22 262205 656e5f555342 1d 636f6d2e676f6f676c652e616e64726f69642e74766c61756e63686572 + // 18ec07 + // 08ec07 12 2a0807 22 262205 en_USB LEN AppName 18ec07 + StringBuilder appName = new StringBuilder(); + String lengthStr = "" + charArray[34] + charArray[35]; + int length = Integer.parseInt(lengthStr, 16) * 2; + for (int i = 36; i < 36 + length; i++) { + appName.append(charArray[i]); + } + logger.debug("{} - Current App: {}", thingId, ShieldTVRequest.encodeMessage(appName.toString())); + callback.setCurrentApp(ShieldTVRequest.encodeMessage(appName.toString())); + } else if (msg.startsWith(MESSAGE_LOWPRIV) && msg.startsWith(MESSAGE_CERT, 10)) { + // Certificate Reply + // |--6-----------12-----------10---------------16---------6--- = 50 characters long + // |080a 12 ad10 08b510 12 a710 0801 12 07 53756363657373 1ac009 3082... 3082... 180a + // |080a 12 9f10 08b510 12 9910 0801 12 07 53756363657373 1ac209 3082... 3082... 180a + // |--------Little Endian Total Payload Length + // |-----------------------Little Endian Remaining Payload Length + // |-----------------------------------Length of SUCCESS + // |--------------------------------------ASCII: SUCCESS + // |-----------------------------------------------------Little Endian Length (e.g. 09c0 and 09c2 above) + // |------------------------------------------------------------Priv Key RSA 2048 + // |--------------------------------------------------------------------Cert X.509 + if (msg.startsWith(MESSAGE_CERT_PAYLOAD, 28)) { + StringBuilder preamble = new StringBuilder(); + StringBuilder privKey = new StringBuilder(); + StringBuilder pubKey = new StringBuilder(); + int i = 0; + int current; + for (; i < 44; i++) { + preamble.append(charArray[i]); + } + logger.trace("{} - Cert Preamble: {}", thingId, preamble.toString()); + + i += 2; // 1a + String st = "" + charArray[i + 2] + "" + charArray[i + 3] + "" + charArray[i] + "" + + charArray[i + 1]; + int privLen = 2246 + ((Integer.parseInt(st, 16) - 2400) * 2); + i += 4; // length + current = i; + + logger.trace("{} - Cert privLen: {} {}", thingId, st, privLen); + + for (; i < current + privLen; i++) { + privKey.append(charArray[i]); + } + + logger.trace("{} - Cert privKey: {} {}", thingId, privLen, privKey.toString()); + + for (; i < msg.length() - 4; i++) { + pubKey.append(charArray[i]); + } + + logger.trace("{} - Cert pubKey: {} {}", thingId, msg.length() - privLen - 4, pubKey.toString()); + + logger.debug("{} - Cert Pair Received privLen: {} pubLen: {}", thingId, privLen, + msg.length() - privLen - 4); + + byte[] privKeyB64Byte = DatatypeConverter.parseHexBinary(privKey.toString()); + byte[] pubKeyB64Byte = DatatypeConverter.parseHexBinary(pubKey.toString()); + + String privKeyB64 = Base64.getEncoder().encodeToString(privKeyB64Byte); + String pubKeyB64 = Base64.getEncoder().encodeToString(pubKeyB64Byte); + + callback.setKeys(privKeyB64, pubKeyB64); + } else { + logger.info("{} - Pin Process Failed.", thingId); + } + } else { + logger.info("{} - Unknown payload received. {}", thingId, msg); + } + } catch (Exception e) { + logger.info("{} - Message Parser Caught Exception", thingId, e); + } + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVRequest.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVRequest.java new file mode 100644 index 0000000000000..051ac5407c11f --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVRequest.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.protocol.shieldtv; + +import static org.openhab.binding.androidtv.internal.AndroidTVBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Contains static methods for constructing LEAP messages + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class ShieldTVRequest { + + public static String encodeMessage(String message) { + StringBuilder reply = new StringBuilder(); + char[] charArray = message.toCharArray(); + for (int i = 0; i < charArray.length; i = i + 2) { + String st = "" + charArray[i] + "" + charArray[i + 1]; + char ch = (char) Integer.parseInt(st, 16); + reply.append(ch); + } + return reply.toString(); + } + + public static String decodeMessage(String message) { + StringBuilder sb = new StringBuilder(); + char ch[] = message.toCharArray(); + for (int i = 0; i < ch.length; i++) { + String hexString = Integer.toHexString(ch[i]); + if (hexString.length() % 2 > 0) { + sb.append('0'); + } + sb.append(hexString); + } + return sb.toString(); + } + + public static String pinRequest(String pin) { + if (PIN_REQUEST.equals(pin)) { + String message = "080a120308cd08"; + return message; + } else { + String prefix = "080a121f08d108121a0a06"; + String encodedPin = decodeMessage(pin); + String suffix = "121036646564646461326639366635646261"; + return prefix + encodedPin + suffix; + } + } + + public static String loginRequest() { + String message = "0801121a0801121073616d73756e6720534d2d4739393855180128fbff04"; + return message; + } + + public static String keepAlive() { + String message = "080028fae0a6c0d130"; + return message; + } + + private static String fixMessage(String tempMsg) { + if (tempMsg.length() % 2 > 0) { + tempMsg = "0" + tempMsg; + } + return tempMsg; + } + + public static String startApp(String message) { + int length = message.length(); + String len1 = fixMessage(Integer.toHexString(length + 6)); + String len2 = fixMessage(Integer.toHexString(length + 2)); + String len3 = fixMessage(Integer.toHexString(length)); + String reply = "08f10712" + len1 + "080212" + len2 + "0a" + len3 + decodeMessage(message); + return reply; + } + // 080b120308cd08 - Longer Hostname Reply + // 08f30712020805 - Unknown + // 08f10712020800 - Get all apps + // 08ec0712020806 - Get current app + + public static String keyboardEntry(String entry) { + // 08ec07120d08081205616263646532020a0a + // 08ec0712 0d 0808 12 05 6162636465 3202 0a0a + int length = entry.length(); + String len1 = fixMessage(Integer.toHexString(length + 8)); + String len2 = fixMessage(Integer.toHexString(length)); + String len3 = fixMessage(Integer.toHexString(length * 2)); + String reply = "08ec0712" + len1 + "080812" + len2 + decodeMessage(entry) + "3202" + len3 + len3; + return reply; + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/utils/AndroidTVPKI.java b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/utils/AndroidTVPKI.java new file mode 100644 index 0000000000000..aad3a8a89a8ac --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/utils/AndroidTVPKI.java @@ -0,0 +1,291 @@ +/** + * Copyright (c) 2010-2023 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.androidtv.internal.utils; + +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AndroidTVPKI} class controls all aspects of the PKI/keyStore + * + * Some methods adapted from Bosch binding + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class AndroidTVPKI { + + private final Logger logger = LoggerFactory.getLogger(AndroidTVPKI.class); + + private final int keySize = 128; + private final int dataLength = 128; + + private String privKey = ""; + private String cert = ""; + private String keystoreFileName = ""; + private String keystoreAlgorithm = "RSA"; + private int keyLength = 2048; + private String alias = "openhab"; + private String distName = "CN=openHAB, O=openHAB, L=None, ST=None, C=None"; + private String cipher = "AES/GCM/NoPadding"; + private String keyAlgorithm = ""; + + private @Nullable Cipher encryptionCipher; + + public AndroidTVPKI() { + try { + encryptionCipher = Cipher.getInstance(cipher); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + logger.debug("Could not get cipher instance", e); + } + } + + public byte[] generateEncryptionKey() { + Key key; + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); + keyGenerator.init(keySize); + key = keyGenerator.generateKey(); + byte[] newKey = key.getEncoded(); + this.keyAlgorithm = key.getAlgorithm(); + return newKey; + } catch (NoSuchAlgorithmException e) { + logger.debug("Could not generate encryption keys", e); + } + return new byte[0]; + } + + private Key convertByteToKey(byte[] keyString) { + Key key = new SecretKeySpec(keyString, keyAlgorithm); + return key; + } + + public String encrypt(String data, Key key) throws Exception { + return encrypt(data, key, this.cipher); + } + + public String encrypt(String data, Key key, String cipher) throws Exception { + byte[] dataInBytes = data.getBytes(); + Cipher encryptionCipher = this.encryptionCipher; + if (encryptionCipher != null) { + encryptionCipher.init(Cipher.ENCRYPT_MODE, key); + byte[] encryptedBytes = encryptionCipher.doFinal(dataInBytes); + return Base64.getEncoder().encodeToString(encryptedBytes); + } else { + return ""; + } + } + + public String decrypt(String encryptedData, Key key) throws Exception { + return decrypt(encryptedData, key, this.cipher); + } + + public String decrypt(String encryptedData, Key key, String cipher) throws Exception { + byte[] dataInBytes = Base64.getDecoder().decode(encryptedData); + Cipher decryptionCipher = Cipher.getInstance(cipher); + Cipher encryptionCipher = this.encryptionCipher; + if (encryptionCipher != null) { + GCMParameterSpec spec = new GCMParameterSpec(dataLength, encryptionCipher.getIV()); + decryptionCipher.init(Cipher.DECRYPT_MODE, key, spec); + byte[] decryptedBytes = decryptionCipher.doFinal(dataInBytes); + return new String(decryptedBytes); + } else { + return ""; + } + } + + public void setPrivKey(String privKey, byte[] keyString) throws Exception { + Key key = convertByteToKey(keyString); + this.privKey = encrypt(privKey, key); + } + + public String getPrivKey(byte[] keyString) throws Exception { + Key key = convertByteToKey(keyString); + return decrypt(this.privKey, key); + } + + public void setCert(String cert) { + this.cert = cert; + } + + public void setCert(Certificate cert) throws CertificateEncodingException { + this.cert = new String(Base64.getEncoder().encode(cert.getEncoded())); + } + + public Certificate getCert() throws CertificateException { + Certificate cert = CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(this.cert.getBytes()))); + return cert; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public String getAlias() { + return this.alias; + } + + public void setAlgorithm(String keystoreAlgorithm) { + this.keystoreAlgorithm = keystoreAlgorithm; + } + + public String getAlgorithm() { + return this.keystoreAlgorithm; + } + + public void setKeyLength(int keyLength) { + this.keyLength = keyLength; + } + + public int getKeyLength() { + return this.keyLength; + } + + public void setDistName(String distName) { + this.distName = distName; + } + + public String getDistName() { + return this.distName; + } + + public void setKeystoreFileName(String keystoreFileName) { + this.keystoreFileName = keystoreFileName; + } + + public String getKeystoreFileName() { + return this.keystoreFileName; + } + + public void setKeys(String privKey, byte[] keyString, String cert) throws GeneralSecurityException, Exception { + setPrivKey(privKey, keyString); + setCert(cert); + } + + public void setKeyStore(String keystoreFileName) { + this.keystoreFileName = keystoreFileName; + } + + public void loadFromKeyStore(String keystoreFileName, String keystorePassword, byte[] keyString) + throws GeneralSecurityException, IOException, Exception { + this.keystoreFileName = keystoreFileName; + loadFromKeyStore(keystorePassword, keyString); + } + + public void loadFromKeyStore(String keystorePassword, byte[] keyString) + throws GeneralSecurityException, IOException, Exception { + Key key = convertByteToKey(keyString); + KeyStore keystore = KeyStore.getInstance("JKS"); + FileInputStream keystoreInputStream = new FileInputStream(this.keystoreFileName); + keystore.load(keystoreInputStream, keystorePassword.toCharArray()); + byte[] byteKey = keystore.getKey(this.alias, keystorePassword.toCharArray()).getEncoded(); + this.privKey = encrypt(new String(Base64.getEncoder().encode(byteKey)), key); + setCert(keystore.getCertificate(this.alias)); + } + + public KeyStore getKeyStore(String keystorePassword, byte[] keyString) + throws GeneralSecurityException, IOException, Exception { + KeyStore keystore = KeyStore.getInstance("JKS"); + keystore.load(null, null); + byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(getPrivKey(keyString)); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes); + KeyFactory kf = KeyFactory.getInstance(this.keystoreAlgorithm); + keystore.setKeyEntry(this.alias, kf.generatePrivate(keySpec), keystorePassword.toCharArray(), + new java.security.cert.Certificate[] { getCert() }); + return keystore; + } + + public void saveKeyStore(String keystorePassword, byte[] keyString) + throws GeneralSecurityException, IOException, Exception { + saveKeyStore(this.keystoreFileName, keystorePassword, keyString); + } + + public void saveKeyStore(String keystoreFileName, String keystorePassword, byte[] keyString) + throws GeneralSecurityException, IOException, Exception { + FileOutputStream keystoreStream = new FileOutputStream(keystoreFileName); + KeyStore keystore = getKeyStore(keystorePassword, keyString); + keystore.store(keystoreStream, keystorePassword.toCharArray()); + } + + private X509Certificate generateSelfSignedCertificate(KeyPair keyPair, String distName) + throws GeneralSecurityException, OperatorCreationException { + final Instant now = Instant.now(); + final Date notBefore = Date.from(now); + final Date notAfter = Date.from(now.plus(Duration.ofDays(365 * 10))); + X500Name name = new X500Name(distName); + X509v3CertificateBuilder certificateBuilder = new JcaX509v3CertificateBuilder(name, + BigInteger.valueOf(now.toEpochMilli()), notBefore, notAfter, name, keyPair.getPublic()); + ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate()); + return new JcaX509CertificateConverter().setProvider(new BouncyCastleProvider()) + .getCertificate(certificateBuilder.build(contentSigner)); + } + + public void generateNewKeyPair(byte[] keyString) + throws GeneralSecurityException, OperatorCreationException, IOException, Exception { + Key key = convertByteToKey(keyString); + KeyPairGenerator kpg = KeyPairGenerator.getInstance(this.keystoreAlgorithm); + kpg.initialize(this.keyLength); + KeyPair kp = kpg.generateKeyPair(); + Security.addProvider(new BouncyCastleProvider()); + Signature signer = Signature.getInstance("SHA256withRSA", "BC"); + signer.initSign(kp.getPrivate()); + signer.update("openhab".getBytes(StandardCharsets.UTF_8)); + signer.sign(); + X509Certificate signedcert = generateSelfSignedCertificate(kp, this.distName); + this.privKey = encrypt(new String(Base64.getEncoder().encode(kp.getPrivate().getEncoded())), key); + setCert(signedcert); + } +} diff --git a/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..dde94687e2e8d --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + AndroidTV Binding + This is the add-on for AndroidTV. + local + + diff --git a/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/i18n/androidtv.properties b/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/i18n/androidtv.properties new file mode 100644 index 0000000000000..d8a2799c2917c --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/i18n/androidtv.properties @@ -0,0 +1,69 @@ +# add-on + +addon.androidtv.name = AndroidTV Binding +addon.androidtv.description = This is the add-on for AndroidTV. + +# thing types + +thing-type.androidtv.googletv.label = GoogleTV +thing-type.androidtv.googletv.description = GoogleTV +thing-type.androidtv.shieldtv.label = ShieldTV +thing-type.androidtv.shieldtv.description = Nvidia ShieldTV + +# thing types config + +thing-type.config.androidtv.googletv.delay.label = Delay +thing-type.config.androidtv.googletv.delay.description = Delay between messages +thing-type.config.androidtv.googletv.heartbeat.label = Heartbeat Frequency +thing-type.config.androidtv.googletv.heartbeat.description = Frequency of heartbeats +thing-type.config.androidtv.googletv.ipAddress.label = Hostname +thing-type.config.androidtv.googletv.ipAddress.description = Hostname or IP address of the device +thing-type.config.androidtv.googletv.keystoreFileName.label = Keystore File Name +thing-type.config.androidtv.googletv.keystoreFileName.description = Java keystore containing key and certs +thing-type.config.androidtv.googletv.keystorePassword.label = Keystore Password +thing-type.config.androidtv.googletv.keystorePassword.description = Password for the keystore file +thing-type.config.androidtv.googletv.port.label = Port +thing-type.config.androidtv.googletv.port.description = Port to connect to +thing-type.config.androidtv.googletv.reconnect.label = Reconnect Delay +thing-type.config.androidtv.googletv.reconnect.description = Delay between reconnection attempts +thing-type.config.androidtv.shieldtv.delay.label = Delay +thing-type.config.androidtv.shieldtv.delay.description = Delay between messages +thing-type.config.androidtv.shieldtv.heartbeat.label = Hearbeat Frequency +thing-type.config.androidtv.shieldtv.heartbeat.description = Frequency of heartbeats +thing-type.config.androidtv.shieldtv.ipAddress.label = Hostname +thing-type.config.androidtv.shieldtv.ipAddress.description = Hostname or IP address of the device +thing-type.config.androidtv.shieldtv.keystoreFileName.label = Keystore File Name +thing-type.config.androidtv.shieldtv.keystoreFileName.description = Java keystore containing key and certs +thing-type.config.androidtv.shieldtv.keystorePassword.label = Keystore Password +thing-type.config.androidtv.shieldtv.keystorePassword.description = Password for the keystore file +thing-type.config.androidtv.shieldtv.port.label = Port +thing-type.config.androidtv.shieldtv.port.description = Port to connect to +thing-type.config.androidtv.shieldtv.reconnect.label = Reconnect Delay +thing-type.config.androidtv.shieldtv.reconnect.description = Delay between reconnection attempts + +# channel types + +channel-type.androidtv.app.label = App +channel-type.androidtv.app.description = App Control +channel-type.androidtv.appname.label = App Name +channel-type.androidtv.appname.description = App Name +channel-type.androidtv.appurl.label = App URL +channel-type.androidtv.appurl.description = App URL +channel-type.androidtv.debug.label = DEBUG Command +channel-type.androidtv.debug.description = Binding control (for debugging) +channel-type.androidtv.keyboard.label = Keyboard +channel-type.androidtv.keyboard.description = Keyboard Entry +channel-type.androidtv.keycode.label = Keycode +channel-type.androidtv.keycode.description = Send keycode +channel-type.androidtv.keypress.label = Key Press +channel-type.androidtv.keypress.description = Send key press +channel-type.androidtv.pincode.label = Pin Code +channel-type.androidtv.pincode.description = Send Pin Code +channel-type.androidtv.player.label = Player +channel-type.androidtv.player.description = Player Control + +# custom thing status +offline.protocols-starting = Protocols Starting +offline.googletv-address-not-specified = googletv address not specified +offline.shieldtv-address-not-specified = shieldtv address not specified + diff --git a/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..9101a2504e692 --- /dev/null +++ b/bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,192 @@ + + + + + + + Nvidia ShieldTV + + + + + + + + + + + + + + + + + + unknown + unknown + unknown + unknown + unknown + unknown + unknown + unknown + + + ipAddress + + + + network-address + + Hostname or IP address of the device + + + + Port to connect to + + + + Java keystore containing key and certs + + + password + + Password for the keystore file + + + + Delay between reconnection attempts + + + + Frequency of heartbeats + + + + Delay between messages + + + + + + + + + GoogleTV + + + + + + + + + + + + + + + + unknown + unknown + unknown + unknown + unknown + + + ipAddress + + + + network-address + + Hostname or IP address of the device + + + + Port to connect to + + + + Java keystore containing key and certs + + + password + + Password for the keystore file + + + + Delay between reconnection attempts + + + + Frequency of heartbeats + + + + Delay between messages + + + + + + + String + + Binding control (for debugging) + + + + String + + App Control + + + + String + + App Name + + + + String + + App URL + + + + String + + Send key press + + + + String + + Send keycode + + + + String + + Keyboard Entry + + + + String + + Send Pin Code + + + + Player + + Player Control + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 07c78bca987f4..9273352cfe9d8 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -57,6 +57,7 @@ org.openhab.binding.ambientweather org.openhab.binding.amplipi org.openhab.binding.androiddebugbridge + org.openhab.binding.androidtv org.openhab.binding.anel org.openhab.binding.astro org.openhab.binding.atlona