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