]> git.basschouten.com Git - openhab-addons.git/commitdiff
[androidtv] AndroidTV Binding initial contribution (#14282)
authormorph166955 <53797132+morph166955@users.noreply.github.com>
Mon, 26 Jun 2023 06:49:42 +0000 (01:49 -0500)
committerGitHub <noreply@github.com>
Mon, 26 Jun 2023 06:49:42 +0000 (08:49 +0200)
Signed-off-by: Ben Rosenblum <rosenblumb@gmail.com>
30 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.androidtv/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/README.md [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVDynamicCommandDescriptionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/AndroidTVHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/GoogleTVDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/discovery/ShieldTVDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConnectionManager.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVMessageParser.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/googletv/GoogleTVUtils.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConnectionManager.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVMessageParser.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/protocol/shieldtv/ShieldTVRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/java/org/openhab/binding/androidtv/internal/utils/AndroidTVPKI.java [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/i18n/androidtv.properties [new file with mode: 0644]
bundles/org.openhab.binding.androidtv/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/pom.xml

index 899c818aa135fb546ab33f517c95a7d4b803d3b4..ae42d31f2b99a6208b320a5bf5483699c163dd08 100644 (file)
@@ -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.anthem/ @mhilbush
 /bundles/org.openhab.binding.astro/ @gerrieg
index e19070fd25c57a2b10c2c29f3a3d0361399a32ce..54161aed4e1c26c770630985f092ba64afca579c 100644 (file)
       <artifactId>org.openhab.binding.androiddebugbridge</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.androidtv</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.anel</artifactId>
diff --git a/bundles/org.openhab.binding.androidtv/NOTICE b/bundles/org.openhab.binding.androidtv/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..b313256
--- /dev/null
@@ -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 (file)
index 0000000..e3a2936
--- /dev/null
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.0.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.androidtv</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: AndroidTV Binding</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcpkix-jdk15on</artifactId>
+      <version>1.52</version>
+      <scope>compile</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcprov-jdk15on</artifactId>
+      <version>1.52</version>
+      <scope>compile</scope>
+    </dependency>
+  </dependencies>
+
+</project>
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 (file)
index 0000000..ae915f5
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.androidtv-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-androidtv" description="AndroidTV Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <feature>openhab-transport-mdns</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.androidtv/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..0838f17
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..9ac7a5f
--- /dev/null
@@ -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 (file)
index 0000000..f439363
--- /dev/null
@@ -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<String, String> cdpMap) {
+        logger.trace("{} - Updating CDP for {}", this.thingID, channelName);
+        List<CommandOption> commandOptions = new ArrayList<CommandOption>();
+        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 (file)
index 0000000..d5690dd
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..96c3852
--- /dev/null
@@ -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<ThingTypeUID> 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<String, Object> 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 (file)
index 0000000..31c2925
--- /dev/null
@@ -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<ThingTypeUID> 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<String, Object> 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 (file)
index 0000000..6efcabf
--- /dev/null
@@ -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 (file)
index 0000000..91fb0d6
--- /dev/null
@@ -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 (file)
index 0000000..d604654
--- /dev/null
@@ -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<GoogleTVCommand> sendQueue = new LinkedBlockingQueue<>();
+    private final BlockingQueue<GoogleTVCommand> 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 (file)
index 0000000..752a72d
--- /dev/null
@@ -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 (file)
index 0000000..a38f371
--- /dev/null
@@ -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 (file)
index 0000000..0c976cd
--- /dev/null
@@ -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 (file)
index 0000000..7747e9c
--- /dev/null
@@ -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 (file)
index 0000000..a67f201
--- /dev/null
@@ -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 (file)
index 0000000..3847612
--- /dev/null
@@ -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 (file)
index 0000000..3fed339
--- /dev/null
@@ -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<ShieldTVCommand> sendQueue = new LinkedBlockingQueue<>();
+    private final BlockingQueue<ShieldTVCommand> 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<String, String> appNameDB = new HashMap<>();
+    private Map<String, String> 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<String, String> appNameDB, Map<String, String> 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 (file)
index 0000000..5961582
--- /dev/null
@@ -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 (file)
index 0000000..2707c22
--- /dev/null
@@ -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<String, String> appNameDB = new HashMap<>();
+                Map<String, String> 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<String, String> sortedAppNameDB = new LinkedHashMap<>();
+                    List<String> valueList = new ArrayList<>();
+                    for (Map.Entry<String, String> entry : appNameDB.entrySet()) {
+                        valueList.add(entry.getValue());
+                    }
+                    Collections.sort(valueList);
+                    for (String str : valueList) {
+                        for (Entry<String, String> 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 (file)
index 0000000..051ac54
--- /dev/null
@@ -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 (file)
index 0000000..aad3a8a
--- /dev/null
@@ -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 (file)
index 0000000..dde9468
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="androidtv" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>binding</type>
+       <name>AndroidTV Binding</name>
+       <description>This is the add-on for AndroidTV.</description>
+       <connection>local</connection>
+
+</addon:addon>
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 (file)
index 0000000..d8a2799
--- /dev/null
@@ -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 (file)
index 0000000..9101a25
--- /dev/null
@@ -0,0 +1,192 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="androidtv"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="shieldtv">
+
+               <label>ShieldTV</label>
+               <description>Nvidia ShieldTV</description>
+
+               <channels>
+                       <channel id="debug" typeId="debug"/>
+                       <channel id="keypress" typeId="keypress"/>
+                       <channel id="keyboard" typeId="keyboard"/>
+                       <channel id="keycode" typeId="keycode"/>
+                       <channel id="pincode" typeId="pincode"/>
+                       <channel id="app" typeId="app"/>
+                       <channel id="appname" typeId="appname"/>
+                       <channel id="appurl" typeId="appurl"/>
+                       <channel id="player" typeId="player"/>
+                       <channel id="power" typeId="system.power"/>
+                       <channel id="volume" typeId="system.volume"/>
+                       <channel id="mute" typeId="system.mute"/>
+               </channels>
+
+               <properties>
+                       <property name="deviceName">unknown</property>
+                       <property name="deviceID">unknown</property>
+                       <property name="architectures">unknown</property>
+                       <property name="manufacturer">unknown</property>
+                       <property name="model">unknown</property>
+                       <property name="androidVersion">unknown</property>
+                       <property name="remoteServer">unknown</property>
+                       <property name="remoteServerVersion">unknown</property>
+               </properties>
+
+               <representation-property>ipAddress</representation-property>
+
+               <config-description>
+                       <parameter name="ipAddress" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Hostname</label>
+                               <description>Hostname or IP address of the device</description>
+                       </parameter>
+                       <parameter name="port" type="integer">
+                               <label>Port</label>
+                               <description>Port to connect to</description>
+                       </parameter>
+                       <parameter name="keystoreFileName" type="text">
+                               <label>Keystore File Name</label>
+                               <description>Java keystore containing key and certs</description>
+                       </parameter>
+                       <parameter name="keystorePassword" type="text">
+                               <context>password</context>
+                               <label>Keystore Password</label>
+                               <description>Password for the keystore file</description>
+                       </parameter>
+                       <parameter name="reconnect" type="integer">
+                               <label>Reconnect Delay</label>
+                               <description>Delay between reconnection attempts</description>
+                       </parameter>
+                       <parameter name="heartbeat" type="integer">
+                               <label>Hearbeat Frequency</label>
+                               <description>Frequency of heartbeats</description>
+                       </parameter>
+                       <parameter name="delay" type="integer">
+                               <label>Delay</label>
+                               <description>Delay between messages</description>
+                       </parameter>
+               </config-description>
+
+       </thing-type>
+
+       <thing-type id="googletv">
+
+               <label>GoogleTV</label>
+               <description>GoogleTV</description>
+
+               <channels>
+                       <channel id="debug" typeId="debug"/>
+                       <channel id="keypress" typeId="keypress"/>
+                       <channel id="keyboard" typeId="keyboard"/>
+                       <channel id="keycode" typeId="keycode"/>
+                       <channel id="pincode" typeId="pincode"/>
+                       <channel id="app" typeId="app"/>
+                       <channel id="player" typeId="player"/>
+                       <channel id="power" typeId="system.power"/>
+                       <channel id="volume" typeId="system.volume"/>
+                       <channel id="mute" typeId="system.mute"/>
+               </channels>
+
+               <properties>
+                       <property name="manufacturer">unknown</property>
+                       <property name="model">unknown</property>
+                       <property name="androidVersion">unknown</property>
+                       <property name="remoteServer">unknown</property>
+                       <property name="remoteServerVersion">unknown</property>
+               </properties>
+
+               <representation-property>ipAddress</representation-property>
+
+               <config-description>
+                       <parameter name="ipAddress" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Hostname</label>
+                               <description>Hostname or IP address of the device</description>
+                       </parameter>
+                       <parameter name="port" type="integer">
+                               <label>Port</label>
+                               <description>Port to connect to</description>
+                       </parameter>
+                       <parameter name="keystoreFileName" type="text">
+                               <label>Keystore File Name</label>
+                               <description>Java keystore containing key and certs</description>
+                       </parameter>
+                       <parameter name="keystorePassword" type="text">
+                               <context>password</context>
+                               <label>Keystore Password</label>
+                               <description>Password for the keystore file</description>
+                       </parameter>
+                       <parameter name="reconnect" type="integer">
+                               <label>Reconnect Delay</label>
+                               <description>Delay between reconnection attempts</description>
+                       </parameter>
+                       <parameter name="heartbeat" type="integer">
+                               <label>Heartbeat Frequency</label>
+                               <description>Frequency of heartbeats</description>
+                       </parameter>
+                       <parameter name="delay" type="integer">
+                               <label>Delay</label>
+                               <description>Delay between messages</description>
+                       </parameter>
+               </config-description>
+
+       </thing-type>
+
+       <channel-type id="debug" advanced="true">
+               <item-type>String</item-type>
+               <label>DEBUG Command</label>
+               <description>Binding control (for debugging)</description>
+       </channel-type>
+
+       <channel-type id="app">
+               <item-type>String</item-type>
+               <label>App</label>
+               <description>App Control</description>
+       </channel-type>
+
+       <channel-type id="appname">
+               <item-type>String</item-type>
+               <label>App Name</label>
+               <description>App Name</description>
+       </channel-type>
+
+       <channel-type id="appurl">
+               <item-type>String</item-type>
+               <label>App URL</label>
+               <description>App URL</description>
+       </channel-type>
+
+       <channel-type id="keypress">
+               <item-type>String</item-type>
+               <label>Key Press</label>
+               <description>Send key press</description>
+       </channel-type>
+
+       <channel-type id="keycode">
+               <item-type>String</item-type>
+               <label>Keycode</label>
+               <description>Send keycode</description>
+       </channel-type>
+
+       <channel-type id="keyboard">
+               <item-type>String</item-type>
+               <label>Keyboard</label>
+               <description>Keyboard Entry</description>
+       </channel-type>
+
+       <channel-type id="pincode">
+               <item-type>String</item-type>
+               <label>Pin Code</label>
+               <description>Send Pin Code</description>
+       </channel-type>
+
+       <channel-type id="player">
+               <item-type>Player</item-type>
+               <label>Player</label>
+               <description>Player Control</description>
+       </channel-type>
+
+</thing:thing-descriptions>
index 5fb1ccee88b301b882a5c874e13e7e35452fec79..cf42108c5e82bf67d359ed6ee3443a905be3a0bc 100644 (file)
@@ -56,6 +56,7 @@
     <module>org.openhab.binding.ambientweather</module>
     <module>org.openhab.binding.amplipi</module>
     <module>org.openhab.binding.androiddebugbridge</module>
+    <module>org.openhab.binding.androidtv</module>
     <module>org.openhab.binding.anel</module>
     <module>org.openhab.binding.anthem</module>
     <module>org.openhab.binding.astro</module>