diff --git a/bundles/org.openhab.binding.samsungtv/NOTICE b/bundles/org.openhab.binding.samsungtv/NOTICE old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/README.md b/bundles/org.openhab.binding.samsungtv/README.md old mode 100644 new mode 100755 index 8002292a9236d..54507e3d8cfa6 --- a/bundles/org.openhab.binding.samsungtv/README.md +++ b/bundles/org.openhab.binding.samsungtv/README.md @@ -4,43 +4,36 @@ This binding integrates the [Samsung TV's](https://www.samsung.com). ## Supported Things -Samsung TV C (2010), D (2011), E (2012) and F (2013) models should be supported. -Also support added for TVs using websocket remote interface (2016+ models) -Because Samsung does not publish any documentation about the TV's UPnP interface, there could be differences between different TV models, which could lead to mismatch problems. - -Tested TV models: - -| Model | State | Notes | -| -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| KU6519 | PARTIAL | Supported channels: `volume`, `mute`, `power`, `keyCode` (at least) | -| LE40D579 | PARTIAL | Supported channels: `volume`, `mute`, `channel`, `keyCode`, `sourceName`, `programTitle`, `channelName`, `power` | -| LE40C650 | PARTIAL | Supported channels: `volume`, `mute`, `channel`, `keyCode`, `brightness`, `contrast`, `colorTemperature`, `power` (only power off, unable to power on) | -| UE40F6500 | OK | All channels except `colorTemperature`, `programTitle` and `channelName` are working | -| UE40J6300AU | PARTIAL | Supported channels: `volume`, `mute`, `sourceName`, `power` | -| UE43MU6199 | PARTIAL | Supported channels: `volume`, `mute`, `power` (at least) | -| UE46D5700 | PARTIAL | Supports at my home only commands via the fake remote, no discovery | -| UE46E5505 | OK | Initial contribution is done by this model | -| UE46F6510SS | PARTIAL | Supported channels: `volume`, `mute`, `channel` (at least) | -| UE48J5670SU | PARTIAL | Supported channels: `volume`, `sourceName` | -| UE50MU6179 | PARTIAL | Supported channels: `volume`, `mute`, `power`, `keyCode`, `channel`, `sourceApp`, `url` | -| UE55LS003 | PARTIAL | Supported channels: `volume`, `mute`, `sourceApp`, `url`, `keyCode`, `power`, `artMode` | -| UE58RU7179UXZG | PARTIAL | Supported channels: `volume`, `mute`, `power`, `keyCode` (at least) | -| UN50J5200 | PARTIAL | Status is retrieved (confirmed `power`, `media title`). Operating device seems not working. | +There is one Thing per TV. ## Discovery -The TV's are discovered through UPnP protocol in the local network and all devices are put in the Inbox. +The TV's are discovered through UPnP protocol in the local network and all devices are put in the Inbox. TV must be ON for this to work. ## Binding Configuration -The binding does not require any special configuration. +Basic operation does not require any special configuration. + +The binding has the following configuration options, which can be set for "binding:samsungtv": + +| Parameter | Name | Description | Required | +|-----------------------|---------------------------|---------------------------------------------------------------|-----------| +| hostName | Host Name | Network address of the Samsung TV | yes | +| port | TCP Port | TCP port of the Samsung TV | no | +| macAddress | MAC Address | MAC Address of the Samsung TV | no | +| refreshInterval | Refresh Interval | States how often a refresh shall occur in milliseconds | no | +| protocol | Remote Control Protocol | The type of remote control protocol | yes | +| webSocketToken | Websocket Token | Security token for secure websocket connection | no | +| subscription | Subscribe to UPNP | Reduces polling on UPNP devices | no | +| smartThingsApiKey | Smartthings PAT | Smartthings Personal Access Token | no | +| smartThingsDeviceId | Smartthings Device ID | Smartthings Device ID for this TV | no | ## Thing Configuration The Samsung TV Thing requires the host name and port address as a configuration value in order for the binding to know how to access it. -Samsung TV publish several UPnP devices and hostname is used to recognize those UPnP devices. +Samsung TV's publish several UPnP devices and the hostname is used to recognize those UPnP devices. Port address is used for remote control emulation protocol. -Additionally, a refresh interval can be configured in milliseconds to specify how often TV resources are polled. +Additionally, a refresh interval can be configured in milliseconds to specify how often TV resources are polled. Default is 1000 ms. E.g. @@ -48,54 +41,351 @@ E.g. Thing samsungtv:tv:livingroom [ hostName="192.168.1.10", port=55000, macAddress="78:bd:bc:9f:12:34", refreshInterval=1000 ] ``` -Different ports are used in different models. It may be 55000, 8001 or 8002. +Different ports are used on different models. It may be 55000, 8001 or 8002. + +If you have a <2016 TV, the interface will be *Legacy*, and the port is likely 55000. +If you have a >2016 TV, the interface will be either *websocket* on port 8001, or *websocketsecure* on port 8002. +If your TV supports *websocketsecure*, you **MUST** use it, otherwise the `keyCode` and all dependent channels will not work. + +In order for the binding to control your TV, you will be asked to accept the remote connection (from openHAB) on your TV. You have 30 seconds to accept the connection. If you fail to accept it, then most channels will not work. +Once you have accepted the connection, the returned token is stored in the binding, so you don't have to repeat this every time openHAB is restarted. + +If the connection has been refused, or you don't have your TV configured to allow remote connections, the binding will not work. If you are having problems, check the settings on your TV, sometimes a family member denies the popup (because they don't know what it is), and after that nothing will work. +You can set the connection to `Allow` on the TV, or delete the openHAB entry, and try the connection again. + +The binding will try to automatically discover the correct protocol for your TV, so **don't change it** unless you know it is wrong. + +Under `advanced`, you can enter a Smartthings PAT, and Device Id. This enables more channels via the Smartthings cloud. This is only for TV's that support Smartthings. No hub is required. The binding will attempt to discover the device ID for your TV automatically, you can enter it manually if automatic detection fails. +Also under `advanced`, you have the ability to turn on *"Subscribe to UPnP events"*. This is off by default. This option reduces (but does not eliminate) the polling of UPnP services. You can enable it if you want to test it out. If you disable this setting (after testing), you should power cycle your TV to remove the old subscriptions. + +For >2019 TV's, there is an app workaround, see [App Discovery](#app-discovery) for details. ## Channels TVs support the following channels: -| Channel Type ID | Item Type | Description | -| ---------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| volume | Dimmer | Volume level of the TV. | -| mute | Switch | Mute state of the TV. | -| brightness | Dimmer | Brightness of the TV picture. | -| contrast | Dimmer | Contrast of the TV picture. | -| sharpness | Dimmer | Sharpness of the TV picture. | -| colorTemperature | Number | Color temperature of the TV picture. Minimum value is 0 and maximum 4. | -| sourceName | String | Name of the current source. | -| sourceId | Number | Id of the current source. | -| channel | Number | Selected TV channel number. | -| programTitle | String | Program title of the current channel. | -| channelName | String | Name of the current TV channel. | -| url | String | Start TV web browser and go the given web page. | -| stopBrowser | Switch | Stop TV's web browser and go back to TV mode. | -| power | Switch | TV power. Some of the Samsung TV models doesn't allow to set Power ON remotely. | -| artMode | Switch | TV art mode for e.g. Samsung The Frame TV's. Only relevant if power=off. If set to on when power=on, the power will be switched off | -| sourceApp | String | Currently active App. | -| keyCode | String | The key code channel emulates the infrared remote controller and allows to send virtual button presses. | +| Channel Type ID | Item Type| Access Mode| Description | +|---------------------|----------|------------|---------------------------------------------------------------------------------------------------------| +| volume | Dimmer | RW | Volume level of the TV. | +| mute | Switch | RW | Mute state of the TV. | +| brightness | Dimmer | RW | Brightness of the TV picture. | +| contrast | Dimmer | RW | Contrast of the TV picture. | +| sharpness | Dimmer | RW | Sharpness of the TV picture. | +| colorTemperature | Number | RW | Color temperature of the TV picture. Minimum value is 0 and maximum 4. | +| sourceName | String | RW | Name of the current source (eg HDMI1). | +| sourceId | Number | RW | Id of the current source. | +| channel | Number | RW | Selected TV channel number. | +| programTitle | String | R | Program title of the current channel. | +| channelName | String | R | Name of the current TV channel. | +| url | String | W | Start TV web browser and go the given web page. | +| stopBrowser | Switch | W | Stop TV's web browser and go back to TV mode. | +| keyCode | String | W | The key code channel emulates the infrared remote controller and allows to send virtual button presses. | +| sourceApp | String | RW | Currently active App. | +| power | Switch | RW | TV power. Some of the Samsung TV models doesn't allow to set Power ON remotely. | +| artMode | Switch | RW | TV art mode for Samsung The Frame TV's. | +| setArtMode | Switch | W | Manual input for setting internal ArtMode tracking for Samsung The Frame TV's >2021. | +| artImage | Image | RW | The currently selected art (thumbnail) | +| artLabel | String | RW | The currently selected art (label) - can also set the current art | +| artJson | String | RW | Send/receive commands from the TV art websocket Channel | +| artBrightness | Dimmer | RW | ArtMode Brightness | +| artColorTemperature | Number | RW | ArtMode Color temperature Minnimum value is -5 and maximum 5 | -E.g. +**NOTE:** channels: brightness, contrast, sharpness, colorTemperature don't work on newer TV's. +**NOTE:** channels: sourceName, sourceId, programTitle, channelName and stopBrowser may need additional configuration. + +Some channels do not work on some TV's. It depends on the age of your TV, and what kind of interface it has. Only link channels that work on your TV, polling channels that your TV doesn't have may cause errors, and other problems. see [Tested TV Models](#tested-tv-models). + +### keyCode channel: + +`keyCode` is a String channel, that emulates a remote control. it allows you to send keys to the TV, as if they were from the remote control, hence it is send only. + +This is one of the more useful channels, and several new features have been added in this binding. +Now all keyCode channel sends are queued, so they don’t overlap each other. You can also now use in line delays, and keypresses (in mS). for example: +sending: +`"KEY_MENU, 1000, KEY_DOWN, KEY_DOWN, KEY_ENTER, 2000, KEY_EXIT"` + +Results in a 1 second pause after `KEY_MENU` before `KEY_DOWN` is sent, and a 2 second delay before `KEY_EXIT` is sent. The other commands have 300mS delays between them. + +**NOTE:** the delay replaces the 300 mS default delay (so 1000 is 1 second, not 1.3 seconds). + +To send keyPresses (like a long press of the power button), you would send: +`"-4000,KEY_POWER"` +This sends a 4 second press of the power button. You can combine these with other commands and delays like this: +`"-3000, KEY_RETURN, 1000, KEY_MENU"` +This does a long press (3 seconds) of the RETURN key (on my TV this exits Netflix or Disney+ etc), then waits 1 second, then exits the menu. + +The delimiter is `,`. + +By not overlapping, I mean that if you send two strings one after the other, they are executed sequentially. ie: +sending +`"-3000, KEY_RETURN, 100, KEY_MENU"` +immediately followed by: +`"KEY_EXIT"` +would send a long press of return, a 1 second pause, then menu, followed by exit 300mS later. + +Spaces are ignored. The supported keys can be listed in the Thing `keyCode` channel + +Mouse events and text entry are now supported. Send `{"x":0, "y":0}` to move the mouse to 0,0, send `LeftClick` or `RightClick` to click the mouse. +Send `"text"` to send the word text to the TV. Any text that you want to send has to be enclosed in `"` to be recognized as a text entry. + +Here is an example to fill in the URL if you launch the browser: + +```java +TV_keyCode.sendCommand("3000,{\"x\":0, \"y\":-430},1000,KEY_ENTER,2000,\"http://your_url_here\"") +``` + +Another example: + +```java +TV_keyCode.sendCommand("{\"x\":0, \"y\":-430},1000,LeftClick") +``` + +**NOTE:** You have to escape the `"` in the string. + +### url + +`url` is a String channel, but on later TV's (>2016) it will not fill in the url for you. It will launch the browser, you can then use a rule to read the url (from the channel) and use the `keyCode` channel to enter the URL. Bit of a kludge, but it works. + +The `sourceApp` channel will show `Internet` (if configured correctly) and sending `""` to the `sourceapp` channel will exit the browser. You can also send `ON` to the `stopBrowser` channel. + +### stopBrowser + +`stopbrowser` is a Switch channel. Sending `ON` exits the current app, sending `OFF` sends a long press of the `KEY_EXIT` button (3 seconds). + +### Power + +The power channel is available on all TV's. Depending on the age of your TV, you may not be able to send power ON commands (see [WOL](#wol)). It should represent the ON state of your TV though. + +## Frame TV's + +Frame TV's have additional channels. +**NOTE:** If you don't have a Frame TV, don't link the `art` channels, it will confuse the binding, especially power control. + +### artMode: + +`artMode` is a Switch channel. When `power` is ON, `artMode` will be OFF. If the `artMode` channel is commanded `OFF`, then the TV will power down to standby/off mode (this takes 4 seconds). +Commanding ON to `artMode` will try to power up the TV in art mode, and commanding ON to `power` will try to power the TV up in ON mode, but see WOL limitations. + +To determine the ON/ART/OFF state of your TV, you have to read both `power` and `artMode`. + +**NOTE:** If you don't have a Frame TV, don't use the `artMode` channel, it will confuse the power handling logic. + +### setArtMode: + +**NOTE** Samsung added back the art API in Firmware 1622 to >2021 Frame TV's. If you have this version of firmware or higher, don't use the `setArtMode` channel, as it is not neccessary. + +`setArtMode` is a Switch channel. Since Samsung removed the art api in 2022, the TV has no way of knowing if it is in art mode or playing a TV source. This switch is to allow you to manually tell the TV what mode it is in. + +If you only use the binding to turn the TV on and off or to Standby, the binding will keep track of the TV state. If, however you use the remote to turn the TV on or off from art mode, the binding cannot detect this, and the power state will become invalid. +This input allows you to set the internal art mode state from an external source (say by monitoring the power usage of the TV, or by querying the ex-link port) - thus keeping the power state consistent. + +**NOTE:** If you don't have a >2021 Frame TV, don't use the `setArtMode` channel, it will confuse the power handling logic. + +### artImage: + +`artImage` is an Image channel that receives a thumbnail of the art that would be displayed in artMode (even if the TV is on). It receives iimages only (you can't send a command to it due to openHAB lmitations). + +### artLabel: + +`artlabel` is a String channel that receives the *intenal* lable of the artwork displayed. This will be something like `MY_0010` or `SAM-0123`. `MY` means it's art you uploaded, `SAM` means its from the Samsung art gallery. +You have to figure out what the label actually represents. + +You can send commands to the channel. It accepts, Strings, string representations of a `Rawtype` image and `RawType` Images. If you send a String, such as `MY-0013`, it will display that art on the TV. If the TV is ON, playing live TV, then the Tv will switch to artMode. +If you send a `RawType` image, then the image (jpg or png or some other common image format) will be uploaded to the TV, and stored in it's internal storage - if you have space. + +The string representation of a `Rawtype` image is of the form `"data:image/png;base64,iVBORw0KGgoAAA........AAElFTkSuQmCC"` where the data is the base64 encoded binary data. the command would look like this: + +```java +TV_ArtLabel.sendCommand("data:image/png;base64,iVBORw0KGgoAAA........AAElFTkSuQmCC") +``` + +here is an example `sitemap` entry: + +```java +Selection item=TV_ArtLabel mappings=["MY_F0061"="Large Bauble","MY_F0063"="Small Bauble","MY_F0062"="Presents","MY_F0060"="Single Bauble","MY_F0055"="Gold Bauble","MY_F0057"="Snowflake","MY_F0054"="Stag","MY_F0056"="Pine","MY_F0059"="Cabin","SAM-S4632"="Snowy Trees","SAM-S2607"="Icy Trees","SAM-S0109"="Whale"] +``` + +### artJson: + +`artJson` is a String channel that receives the output of the art websocket channel on the TV. You can also send commands to this channel. + +If you send a plain text command, the command is wrapped in the required formatting, and sent to the TV artChannel. you can use this feature to send any supported command to the TV, the response will be returned on the same channel. +If you wrap the command with `{` `}`, then the whole string is treated as a json command, and sent as-is to the channel (basic required fields will be added). + +Currently known working commands for 2021 and earlier TV's are: + +``` + get_api_version + get_artmode_status + set_artmode_status "value" on or off + get_auto_rotation_status + set_auto_rotation_status "type" is "slideshow" pr 'shuffelslideshow", "value" is off or duration in minutes "category_id" is a string representing the category + get_device_info + get_content_list + get_current_artwork + get_thumbnail - downloads thumbnail in same format as uploaded + send_image - uploads image jpg/png etc + delete_image_list - list in "content_id" + select_image - selects image to display (display optional) image label in "content_id", "show" true or false + get_photo_filter_list + set_photo_filter + get_matte_list + set_matte + get_motion_timer (and set) valid values: "off","5","15","30","60","120","240", send settiing in "value" + get_motion_sensitivity (and set) min 1 max 3 set in "value" + get_color_temperature (and set) min -5 max +5 set in "value" + get_brightness (and set) min 1 max 10 set in "value" + get_brightness_sensor_setting (and set) on or off in "value" +``` + +Currently known working commands for 2022 and later TV's are: + +``` + api_version + get_artmode_status + set_artmode_status "value" on or off + get_slideshow_status + set_slideshow_status "type" is "slideshow" pr 'shuffelslideshow", "value" is off or duration in minutes "category_id" is a string representing the category + get_device_info + get_content_list + get_current_artwork + get_thumbnail_list - downloads list of thumbnails in same format as uploaded + send_image - uploads image jpg/png etc + delete_image_list - list in "content_id" + select_image - selects image to display (display optional) image label in "content_id", "show" true or false + get_photo_filter_list + set_photo_filter + get_matte_list + set_matte + get_artmode_settings - returns the below values + set_motion_timer valid values: "off","5","15","30","60","120","240", send settiing in "value" + set_motion_sensitivity min 1 max 3 set in "value" + set_color_temperature min -5 max +5 set in "value" + set_brightness min 1 max 10 set in "value" + set_brightness_sensor_setting on or off in "value" +``` + +Some of these commands are quite complex, so I don't reccomend using them eg `get_thumbnail`, `get_thumbnail_list` and `send_image`. +Some are simple, so to get the list of art currently on your TV, just send: + +```java +TV_ArtJson.sendCommand("get_content_list") +``` + +To set the current artwork, but not display it, you would send: + +```java +TV_ArtJson.sendCommand("{\"request\":\"select_image\", \"content_id\":\"MY_0009\",\"show\":false}") +``` + +**NOTE:** You have to escape the `"` in the json string. + +These are just the commands I know, there are probably others, let me know if you find more that work. + +### artbrightness: + +`artBrightness` is a dimmer channel that sets the brightness of the art in ArtMode. It does not affect the TV brightness. Normally the brightness of the artwork is controlled automatically, and the current value is polled and reported via this channel. +You can change the brightness of the artwork (but automatic control is still enabled, unless you turn it off). + +There are only 10 levels of brighness, so you could use a `Setpoint` control for this channel in your `sitemap` - eg: + +```java +Slider item=TV_ArtBrightness visibility=[TV_ArtMode==ON] +Setpoint item=TV_ArtBrightness minValue=0 maxValue=100 step=10 visibility=[TV_ArtMode==ON] +``` + +### artColorTemperature: + +`artColorTemperature` is a Number channel, it reports the "warmth" of the artwork from -5 to 5 (default 0). It's not polled, but is updated when artmode status is updated. +You can use a `Setpoint` contol for this item in your `sitemap` eg: + +```java +Setpoint item=TV_ArtColorTemperature minValue=-5 maxValue=5 step=1 visibility=[TV_ArtMode==ON] +``` + +## Full Example + +### samsungtv.things + +you can configure the Thing and/or channels/items in text files. The Text configuration for the Thing is like this: + +```java +Thing samsungtv:tv:family_room "Samsung The Frame 55" [ hostName="192.168.100.73", port=8002, macAddress="10:2d:42:01:6d:17", refreshInterval=1000, protocol="SecureWebSocket", webSocketToken="16225986", smartThingsApiKey="cae5ac2a-6770-4fa4-a531-4d4e415872be", smartThingsDeviceId="996ff19f-d12b-4c5d-1989-6768a7ad6271", subscription=true ] +``` + +### samsungtv.items + +Channels and items follow the usual conventions. ```java Group gLivingRoomTV "Living room TV" Dimmer TV_Volume "Volume" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:volume" } Switch TV_Mute "Mute" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:mute" } -String TV_SourceName "Source Name" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:sourceName" } -String TV_SourceApp "Source App" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:sourceApp" } -String TV_ProgramTitle "Program Title" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:programTitle" } -String TV_ChannelName "Channel Name" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:channelName" } +String TV_SourceName "Source Name [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:sourceName" } +String TV_SourceApp "Source App [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:sourceApp" } +String TV_ProgramTitle "Program Title [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:programTitle" } +String TV_ChannelName "Channel Name [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:channelName" } String TV_KeyCode "Key Code" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:keyCode" } -Switch TV_Power "Power" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:power" } -Switch TV_ArtMode "Art Mode" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artMode" } +Switch TV_Power "Power [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:power" } +Switch TV_ArtMode "Art Mode [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artMode" } +Switch TV_SetArtMode "Set Art Mode [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:setArtMode" } +String TV_ArtLabel "Current Art [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artLabel" } +Image TV_ArtImage "Current Art" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artImage" } +String TV_ArtJson "Art Json [%s]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artJson" } +Dimmer TV_ArtBri "Art Brightness [%d%%]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artBrightness" } +Number TV_ArtCT "Art CT [%d]" (gLivingRoomTV) { channel="samsungtv:tv:livingroom:artColorTemperature" } ``` -### Apps +## WOL + +Wake on Lan is supported by Samsung TV’s after 2016. The binding will attempt to use WOL to turn on a TV, if `power` (or `artMode`) is commanded ON. +This only works on TV's after 2016, and has some quirks. + +* Does not work on TV's <2016 +* Does not work on hardwired ethernet connected TV's **if you have a soundbar connected via ARC/eARC** +* Works on WiFi connected TV's (with or without soundbar) +* May need to enable this function on the TV +* May have to wait up to 1 minute before turning TV back on, as TV does not power down immediately (and so doesn't respond to WOL) + +You will have to experiment to see if it works for you. If not, you can power on the TV using IR (if you have a Harmony Hub, or GC iTach or similar). +## Apps + +The `sourceApp` channel is a string channel, it displays the name of the current app, `artMode` or `slideshow` if the TV is in artMode, or blank for regular TV. +You can launch an app, by sending its name or appID to the channel. if you send `""` to the channel, it closes the current app. + +Here is an example `sitemap` entry: + +```java +Switch item=TV_SourceApp mappings=["Netflix"="Netflix","Apple TV"="Apple TV","Disney+"="Disney+","Tubi"="Tubi","Internet"="Internet",""="Exit"] +``` + +### Frame TV + +On a Frame TV, you can start a slideshow by sending the slideshow type, followed by a duration (and optional category) eg: + +```java +TV_SourceApp.sendCommand("shuffleslideshow,1440") +``` + +or a sitemap entry: + +```java +Switch item=TV_SourceApp label="Slideshow" mappings=["shuffleslideshow,1440"="shuffle 1 day","suffleslideshow,3"="shuffle 3 mins","slideshow,1440"="slideshow 1 day","slideshow,off"="Off"] +``` + +Sending `slideshow,off` turns the slideshow feature of the TV off. + +### App Discovery + +Apps are automatically discovered on TV's >2015 and <2020 (or 2019 it's not clear when the API was removed). + +**NOTE:** This is an old Apps list, on later TV's the app ID's have changed. List of known apps and the respective name that can be passed on to the `sourceApp` channel. Values are confirmed to work on UE50MU6179. | App | Value in sourceApp | Description | -| ------------- | ------------------ | --------------------------------- | +|---------------|--------------------|-----------------------------------| | ARD Mediathek | `ARD Mediathek` | German public TV broadcasting app | | Browser | `Internet` | Built-in WWW browser | | Netflix | `Netflix` | Netflix App | @@ -103,4 +393,294 @@ Values are confirmed to work on UE50MU6179. | YouTube | `YouTube` | YouTube App | | ZDF Mediathek | `ZDF mediathek` | German public TV broadcasting app | -To discover all installed apps names, you can enable the DEBUG log output from the binding to see a list. +To discover all installed apps names, you can enable the DEBUG log output from the binding to see a list of apps that have been discovered as installed. This list is displayed once, shortly after the TV is turned On. + +If you have a TV >2019, then the list of apps will not be discovered. Instead, a default list of known appID's is built into the binding, these cover most common apps. The binding will attempt to discover these apps, and, if you are lucky, your app will be found and you have nothing further to do. It is possible that new apps have been added, or are specific to your country that are not in the built in list, in which case you can add these apps manually. + +#### Adding apps manually + +If the app you need is not discovered, a file `samsungtv.cfg` will need to be be created in the openHAB config services directory (`/etc/openhab/services/` for Linux systems). + +You need to edit the file `samsungtv.cfg`, and add in the name, appID, and type of the apps you have installed on your TV. Here is a sample for the contents of the `samsungtv.cfg` file: + +```java +# This file is for the samsungtv binding +# It contains a list in json format of apps that can be run on the TV +# It is provided for TV >2020 when the api that returns a list of installed apps was removed +# format is: +# { "name":"app name", "appId":"app id", "type":2 } +# Where "app name" is the plain text name used to start or display the app, eg "Netflix", "Disney+" +# "app id" is the internal appId assigned by Samsung in the app store. This is hard to find +# See https://github.com/tavicu/homebridge-samsung-tizen/wiki/Applications for the details +# app id is usually a 13 digit number, eg Netflix is "3201907018807" +# the type is an integer, either 2 or 4. 2 is DEEP_LINK (all apps are this type on >2020 TV's) +# type 4 is NATIVE_LAUNCH and the only app that used to use this was "com.tizen.browser" for the +# built in webbrowser. +# This default list will be overwritten by the list retrived from the TV (if your TV is prior to 2020) +# You should edit this list down to just the apps you have installed on your TV. +# NOTE! it is unknown how accurate this list is! +# +# +{ "name":"Internet" , "appId":"3202010022079" , "type":2 } +{ "name":"Netflix" , "appId":"3201907018807" , "type":2 } +{ "name":"YouTube" , "appId":"111299001912" , "type":2 } +{ "name":"YouTube TV" , "appId":"3201707014489" , "type":2 } +{ "name":"YouTube Kids" , "appId":"3201611010983" , "type":2 } +{ "name":"HBO Max" , "appId":"3201601007230" , "type":2 } +{ "name":"Hulu" , "appId":"3201601007625" , "type":2 } +{ "name":"Plex" , "appId":"3201512006963" , "type":2 } +{ "name":"Prime Video" , "appId":"3201910019365" , "type":2 } +{ "name":"Rakuten TV" , "appId":"3201511006428" , "type":2 } +{ "name":"Disney+" , "appId":"3201901017640" , "type":2 } +{ "name":"NOW TV" , "appId":"3201603008746" , "type":2 } +{ "name":"NOW PlayTV" , "appId":"3202011022131" , "type":2 } +{ "name":"VOYO.RO" , "appId":"111299000769" , "type":2 } +{ "name":"Discovery+" , "appId":"3201803015944" , "type":2 } +{ "name":"Apple TV" , "appId":"3201807016597" , "type":2 } +{ "name":"Apple Music" , "appId":"3201908019041" , "type":2 } +{ "name":"Spotify" , "appId":"3201606009684" , "type":2 } +{ "name":"TIDAL" , "appId":"3201805016367" , "type":2 } +{ "name":"TuneIn" , "appId":"121299000101" , "type":2 } +{ "name":"Deezer" , "appId":"121299000101" , "type":2 } +{ "name":"Radio UK" , "appId":"3201711015226" , "type":2 } +{ "name":"Radio WOW" , "appId":"3202012022468" , "type":2 } +{ "name":"Steam Link" , "appId":"3201702011851" , "type":2 } +{ "name":"Gallery" , "appId":"3201710015037" , "type":2 } +{ "name":"Focus Sat" , "appId":"3201906018693" , "type":2 } +{ "name":"PrivacyChoices" , "appId":"3201909019271" , "type":2 } +{ "name":"AntenaPlay.ro" , "appId":"3201611011005" , "type":2 } +{ "name":"Eurosport Player" , "appId":"3201703012079" , "type":2 } +{ "name":"EduPedia" , "appId":"3201608010385" , "type":2 } +{ "name":"BBC News" , "appId":"3201602007865" , "type":2 } +{ "name":"BBC Sounds" , "appId":"3202003020365" , "type":2 } +{ "name":"BBC iPlayer" , "appId":"3201601007670" , "type":2 } +{ "name":"The Weather Network" , "appId":"111399000741" , "type":2 } +{ "name":"Orange TV Go" , "appId":"3201710014866" , "type":2 } +{ "name":"Facebook Watch" , "appId":"11091000000" , "type":2 } +{ "name":"ITV Hub" , "appId":"121299000089" , "type":2 } +{ "name":"UKTV Play" , "appId":"3201806016432" , "type":2 } +{ "name":"All 4" , "appId":"111299002148" , "type":2 } +{ "name":"VUDU" , "appId":"111012010001" , "type":2 } +{ "name":"Explore Google Assistant", "appId":"3202004020674" , "type":2 } +{ "name":"Amazon Alexa" , "appId":"3202004020626" , "type":2 } +{ "name":"My5" , "appId":"121299000612" , "type":2 } +{ "name":"SmartThings" , "appId":"3201910019378" , "type":2 } +{ "name":"BritBox" , "appId":"3201909019175" , "type":2 } +{ "name":"TikTok" , "appId":"3202008021577" , "type":2 } +{ "name":"RaiPlay" , "appId":"111399002034" , "type":2 } +{ "name":"DAZN" , "appId":"3201806016390" , "type":2 } +{ "name":"McAfee Security" , "appId":"3201612011418" , "type":2 } +{ "name":"hayu" , "appId":"3201806016381" , "type":2 } +{ "name":"Tubi" , "appId":"3201504001965" , "type":2 } +{ "name":"CTV" , "appId":"3201506003486" , "type":2 } +{ "name":"Crave" , "appId":"3201506003488" , "type":2 } +{ "name":"MLB" , "appId":"3201603008210" , "type":2 } +{ "name":"Love Nature 4K" , "appId":"3201703012065" , "type":2 } +{ "name":"SiriusXM" , "appId":"111399002220" , "type":2 } +{ "name":"7plus" , "appId":"3201803015934" , "type":2 } +{ "name":"9Now" , "appId":"3201607010031" , "type":2 } +{ "name":"Kayo Sports" , "appId":"3201910019354" , "type":2 } +{ "name":"ABC iview" , "appId":"3201812017479" , "type":2 } +{ "name":"10 play" , "appId":"3201704012147" , "type":2 } +{ "name":"Telstra" , "appId":"11101000407" , "type":2 } +{ "name":"Telecine" , "appId":"3201604009182" , "type":2 } +{ "name":"globoplay" , "appId":"3201908019022" , "type":2 } +{ "name":"DIRECTV GO" , "appId":"3201907018786" , "type":2 } +{ "name":"Stan" , "appId":"3201606009798" , "type":2 } +{ "name":"BINGE" , "appId":"3202010022098" , "type":2 } +{ "name":"Foxtel" , "appId":"3201910019449" , "type":2 } +{ "name":"SBS On Demand" , "appId":"3201510005981" , "type":2 } +{ "name":"Security Center" , "appId":"3202009021877" , "type":2 } +{ "name":"Google Duo" , "appId":"3202008021439" , "type":2 } +{ "name":"Kidoodle.TV" , "appId":"3201910019457" , "type":2 } +{ "name":"Embly" , "appId":"vYmY3ACVaa.emby" , "type":2 } +{ "name":"Viaplay" , "appId":"niYSnzL6h1.Viaplay" , "type":2 } +{ "name":"SF Anytime" , "appId":"sntmlv8LDm.SFAnytime" , "type":2 } +{ "name":"SVT Play" , "appId":"5exPmCT0nz.svtplay" , "type":2 } +{ "name":"TV4 Play" , "appId":"cczN3dzcl6.TV4" , "type":2 } +{ "name":"C More" , "appId":"7fEIL5XfcE.CMore" , "type":2 } +{ "name":"Comhem Play" , "appId":"SQgb61mZHw.ComhemPlay" , "type":2 } +{ "name":"Viafree" , "appId":"hs9ONwyP2U.ViafreeBigscreen" , "type":2 } +``` + +Enter this into the `samsungtv.cfg` file and save it. The file contents are read automatically every time the file is updated. The binding will check to see if the app is installed, and start polling the status every 10 seconds (or more if your refresh interval is set higher). +Apps that are not installed are deleted from the list (internally, the file is not updated). If you install an app on the TV, which is not in the built in list, you have to update the file with it's appID, or at least touch the file for the new app to be registered with the binding. + +The entry for `Internet` is important, as this is the TV web browser App. on older TV's it's `org.tizen.browser`, but this is not correct on later TV's (>2019). This is the app used for the `url` channel, so it needs to be set correctly if you use this channel. +`org.tizen.browser` is the internal default, and does launch the browser on all TV's, but on later TV's this is just an alias for the actual app, so the `sourceApp` channel will not be updated correctly unless the correct appID is entered here. The built in list has the correct current appID for the browser, but if it changes or is incorrect for your TV, you can update it here. + +You can use any name you want in this list, as long as the appID is valid. The binding will then allow you to launch the app using your name, the official name, or the appID. + +## Smartthings + +In order to be able to control the TV input (HDMI1, HDMI2 etc), you have to link the binding to the smartthngs API, as there is no local control capable of switching the TV input. +There are several steps required to enable this feature, and no hub is needed. +In order to connect to the Smartthings cloud, there are a few steps to take. + +1. Set the samsungtv logs to at least DEBUG +2. Create a Samsung account (probably already have one when you set up your TV) +3. Add Your TV to the Smartthings App +4. Go to https://account.smartthings.com/tokens and create a Personal Access Token (PAT). check off all the features you want (I would add them all). +5. Go to the openHAB Samsung TV Thing, and update the configuration with your PAT (click on advanced). You will fill in Device ID later if necessary. +6. Save the Thing, and watch the logs. + +The binding will attempt to find the Device ID for your TV. If you have several TV’s of the same type, you will have to manually identify the Device ID for the current Thing from the logs. The device ID should look something like 996ff19f-d12b-4c5d-1989-6768a7ad6271. If you have only one TV of each type, Device ID should get filled in for you. +You can now link the `sourceName`, `sourceId`, `channel` and `channelName` channels, and should see the values updating. You can change the TV input source by sending `"HDMI1"`, or `"HDMI2"` to the `sourceName` channel, the exact string will depend on your TV, and how many inputs you have. You can also send a number to the `sourceId` channel. + +**NOTE:** You may not get anything for `channelName`, as most TV’s don’t report it. You can only send commands to `channel`, `sourceName` and `sourceId`, `channelName` is read only. + +## UPnP Subscriptions + +UPnP Subscriptions are supported. This is an experimental feature which reduces the polling of UPnP services (off by default). + +## Tested TV Models + +Remote control channels (eg power, keyCode): +Samsung TV C (2010), D (2011), E (2012) and F (2013) models should be supported via the legacy interface. +Samsung TV H (2014) and J (2015) are **NOT supported** - these TV's use a pin code for access, and encryption for commands. +Samsung TV K (2016) and onwards are supported via websocket interface. + +Even if the remote control channels are not supported, the UPnP channels may still work. + +Art channels on all Frame TV's are supported. + +Because Samsung does not publish any documentation about the TV's UPnP interface, there could be differences between different TV models, which could lead to mismatch problems. + +Tested TV models (but this table may be out of date): + +| Model | State | Notes | +|----------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| KU6519 | PARTIAL | Supported channels: `volume`, `mute`, `power`, `keyCode` (at least) | +| LE40D579 | PARTIAL | Supported channels: `volume`, `mute`, `channel`, `keyCode`, `sourceName`, `programTitle`, `channelName`, `power` | +| LE40C650 | PARTIAL | Supported channels: `volume`, `mute`, `channel`, `keyCode`, `brightness`, `contrast`, `colorTemperature`, `power` (only power off, unable to power on) | +| UE40F6500 | OK | All channels except `colorTemperature`, `programTitle` and `channelName` are working | +| UE40J6300AU | PARTIAL | Supported channels: `volume`, `mute`, `sourceName`, `power` | +| UE43MU6199 | PARTIAL | Supported channels: `volume`, `mute`, `power` (at least) | +| UE46D5700 | PARTIAL | Supports at my home only commands via the fake remote, no discovery | +| UE46E5505 | OK | Initial contribution is done by this model | +| UE46F6510SS | PARTIAL | Supported channels: `volume`, `mute`, `channel` (at least) | +| UE48J5670SU | PARTIAL | Supported channels: `volume`, `sourceName` | +| UE50MU6179 | PARTIAL | Supported channels: `volume`, `mute`, `power`, `keyCode`, `channel`, `sourceApp`, `url` | +| UE55LS003 | PARTIAL | Supported channels: `volume`, `mute`, `sourceApp`, `url`, `keyCode`, `power`, `artMode` | +| UE58RU7179UXZG | PARTIAL | Supported channels: `volume`, `mute`, `power`, `keyCode` (at least) | +| UN50J5200 | PARTIAL | Status is retrieved (confirmed `power`, `media title`). Operating device seems not working. | +| UN46EH5300 | OK | All channels except `programTitle` and `channelName` are working | +| UE75MU6179 | PARTIAL | All channels except `brightness`, `contrast`, `colorTemperature` and `sharpness` | +| QN55LS03AAFXZC | PARTIAL | Supported channels: `volume`, `mute`, `keyCode`, `power`, `artMode`, `url`, `artImage`, `artLabel`, `artJson`, `artBrightness`,`artColorTemperature` | +| QN43LS03BAFXZC | PARTIAL | Supported channels: `volume`, `mute`, `keyCode`, `power`, `artMode`, `url`, `artImage`, `artLabel`, `artJson`, `artBrightness`,`artColorTemperature` | + +If you enable the Smartthings interface, this adds back the `sourceName`, `sourceId`, `programTitle` and `channelName` channels on >2016 TV's +Samsung removed the app API support in >2019 TV's, if your TV is >2019, see the section on [Apps](#apps). +Samsung removed the art API support in >2021 TV's, if your Frame TV is >2021, see the section on [setArtMode](#setartmode). +Samsung re-introduced the art API in firmware 1622 for >2021 Frame TV's. if you have ths version, art channels will work correctly. + +**NOTE:** `brightness`, `contrast`, `colorTemperature` and `sharpness` channels only work on legacy interface TV's (<2016). + +## Troubleshooting + +On legacy TV's, you may see an error like this: + +``` +2021-12-08 12:19:50.262 [DEBUG] [port.upnp.internal.UpnpIOServiceImpl] - Error reading SOAP response message. Can't transform message payload: org.jupnp.model.action.ActionException: The argument value is invalid. Invalid number of input or output arguments in XML message, expected 2 but found 1. +``` + +This is not an actual error, but is what is returned when a value is polled that does not yet exist, such as the URL for the TV browser, when the browser isn’t running. These messages are not new, and can be ignored. Enabling `subscription` will eliminate them. + +The `getSupportedChannelNames` messages are not UPnP services, they are not actually services that are supported *by your TV* at all. They are the internal capabilities of whatever method is being used for communication (which could be direct port connection, UPnP or websocket). +They also do not reflect the actual capabilities of your TV, just what that method supports, on your TV, they may do nothing. + +You should get `volume` and `mute` channels working at the minnimum. Other channels may or may not work, depending on your TV and the binding configuration. + +If you see errors that say `no route to host` or similar things, it means your TV is off. The binding cannot discover, control or poll a TV that is off. + +For the binding to function properly it is very important that your network config allows the machine running openHAB to receive UPnP multicast traffic. +Multicast traffic is not propogated between different subnets, or VLANS, unless you specifically configure your router to do this. Many switches have IGMP Snooping enabled by default, which filters out multicast traffic. +If you want to check the communication between the machine and the TV is working, you can try the following: + +### Check if your Linux machine receives multicast traffic + +**With your TV OFF (ie totally off)** + +- Login to the Linux console of your openHAB machine. +- make sure you have __netcat__ installed +- Enter `netcat -ukl 1900` or `netcat -ukl -p 1900` depending on your version of Linux + +### Check if your Windows/Mac machine receives multicast traffic + +**With your TV OFF (ie totally off)** + +- Download Wireshark on your openHAB machine +- Start and select the network interface which is connected to the same network as the TV +- Filter for the multicast messages with the expression `udp.dstport == 1900 && data.text` if you have "Show data as text" enabled, otherwise just filter for `udp.dstport == 1900` + +### What you should see + +You may see some messages (this is a good thing, it means you are receiving UPnP traffic). + +Now turn your TV ON (with the remote control). + +You should see several messages like the following: + +``` +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=1800 +DATE: Tue, 18 Jan 2022 17:07:18 GMT +LOCATION: http://192.168.100.73:9197/dmr +NT: urn:schemas-upnp-org:device:MediaRenderer:1 +NTS: ssdp:alive +SERVER: SHP, UPnP/1.0, Samsung UPnP SDK/1.0 +USN: uuid:ea645e34-d3dd-4b9b-a246-e1947f8973d6::urn:schemas-upnp-org:device:MediaRenderer:1 +``` + +Where the ip address in `LOCATION` is the ip address of your TV, and the `USN` varies. `MediaRenderer` is the most important service, as this is what the binding uses to detect if your TV is online/turned On or not. + +If you now turn your TV off, you will see similar messages, but with `NTS: ssdp:byebye`. This is how the binding detects that your TV has turned OFF. + +Try this several times over a period of 30 minutes after you have discovered the TV and added the binding. This is because when you discover the binding, a UPnP `M-SEARCH` packet is broadcast, which will enable mulicast traffic, but your network (router or switches) can eventually start filtering out multicast traffic, leading to unrealiable behaviour. +If you see these messages, then basic communications is working, and you should be able to turn your TV Off (and on later TV's) ON, and have the status reported correctly. + +### Multiple network interfaces + +If you have more than one network interface on your openHAB machine, you may have to change the `Network` setings in the openHAB control panel. Make sure the `Primary Address` is selected correctly (The same subnet as your TV is connected to). + +### I'm not seeing any messages, or not Reliably + +- Most likely your machine is not receiving multicast messages +- Check your network config: + - Routers often block multicast - enable it. + - Make sure the openHAB machine and the TV are in the same subnet/VLAN. + - disable `IGMP Snooping` if it is enabled on your switches. + - enable/disable `Enable multicast enhancement (IGMPv3)` if you have it (sometimes this helps). + - Try to connect your openHAB machine or TV via Ethernet instead of WiFi (AP's can filter Multicasts). + - Make sure you don't have any firewall rules blocking multicast. + - if you are using a Docker container, ensure you use the `--net=host` setting, as Docker filters multicast broadcasts by default. + +### I see the messages, but something else is not working properly + +There are several other common issues that you can check for: + +- Your TV is not supported. H (2014) and J (2015) TV's are not supported, as they have an encrypted interface. +- You are trying to discover a TV that is OFF (some TV's have a timeout, and turn off automatically). +- Remote control is not enabled on your TV. You have to specifically enable IP control and WOL on the TV. +- You have not accepted the request to allow remote control on your TV, or, you denied the request previously. +- You have selected an invalid combination of protocol and port in the binding. + - The binding will attempt to auto configure the correct protocol and port on discovery, but you can change this later to an invalid configuration, eg: + - Protocol None is not valid + - Protocol Legacy will not work on >2016 TV's + - Protocol websocket only works with port 8001 + - Protocol websocketsecure only works with port 8002. If your TV supports websocketsecure on port 8002, you *must* use it, or many things will not work. +- The channel you are trying to use is not supported on your TV. + - Only some channels are supported on different TV's + - Some channels require additional configuration on >2016 TV's. eg `SmartThings` configuration, or Apps confguration. + - Some channels are read only on certain TV's +- I can't turn my TV ON. + - Older TV's (<2016) do not support tuning ON + - WOL is not enabled on your TV (you have to specifically enable it) + - You have a soundbar connected to your TV and are connected using wired Ethernet. + - The MAC address in the binding configuratiion is blank/wrong. + - You have to wait up to 60 seconds after turning OFF, before you can turn back ON (This is a Samsung feature called "instant on") +- My TV asks me to accept the connection every time I turn the TV on + - You have the TV set to "Always Ask" for external connections. You need to set it to "Only ask the First Time". To get to the Device Manager, press the home button on your TV remote and navigate to Settings → General → External Device Manager → Device Connect Manager and change the setting. + - You are using a text `.things` file entry for the TV `thing`, and you haven't entered the `webSocketToken` in the text file definition. The token is shown on the binding config page. See [Binding Configuration](#binding-configuration). + diff --git a/bundles/org.openhab.binding.samsungtv/pom.xml b/bundles/org.openhab.binding.samsungtv/pom.xml old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/feature/feature.xml b/bundles/org.openhab.binding.samsungtv/src/main/feature/feature.xml old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvAppWatchService.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvAppWatchService.java new file mode 100755 index 0000000000000..faf5ea11243b1 --- /dev/null +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvAppWatchService.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2010-2024 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.samsungtv.internal; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerWebSocket; +import org.openhab.core.OpenHAB; +import org.openhab.core.service.WatchService; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link SamsungTvAppWatchService} provides a list of apps for >2020 Samsung TV's + * File should be in json format + * + * @author Nick Waterton - Initial contribution + * @author Nick Waterton - Refactored to new WatchService + */ +@Component(service = SamsungTvAppWatchService.class) +@NonNullByDefault +public class SamsungTvAppWatchService implements WatchService.WatchEventListener { + private static final String APPS_PATH = OpenHAB.getConfigFolder() + File.separator + "services"; + private static final String APPS_FILE = "samsungtv.cfg"; + + private final Logger logger = LoggerFactory.getLogger(SamsungTvAppWatchService.class); + private final RemoteControllerWebSocket remoteControllerWebSocket; + private String host = ""; + private boolean started = false; + int count = 0; + + public SamsungTvAppWatchService(String host, RemoteControllerWebSocket remoteControllerWebSocket) { + this.host = host; + this.remoteControllerWebSocket = remoteControllerWebSocket; + } + + public void start() { + File file = new File(APPS_PATH, APPS_FILE); + if (file.exists() && !getStarted()) { + logger.info("{}: Starting Apps File monitoring service", host); + started = true; + readFileApps(); + } else if (count++ == 0) { + logger.warn("{}: cannot start Apps File monitoring service, file {} does not exist", host, file.toString()); + remoteControllerWebSocket.addKnownAppIds(); + } + } + + public boolean getStarted() { + return started; + } + + /** + * Check file path for existance + * + */ + public boolean checkFileDir() { + File file = new File(APPS_PATH, APPS_FILE); + return file.exists(); + } + + public void readFileApps() { + processWatchEvent(WatchService.Kind.MODIFY, Paths.get(APPS_PATH, APPS_FILE)); + } + + public boolean watchSubDirectories() { + return false; + } + + @Override + public void processWatchEvent(WatchService.Kind kind, Path path) { + if (path.endsWith(APPS_FILE) && kind != WatchService.Kind.DELETE) { + logger.debug("{}: Updating Apps list from FILE {}", host, path); + try { + @SuppressWarnings("null") + List allLines = Files.lines(path).filter(line -> !line.trim().startsWith("#")) + .collect(Collectors.toList()); + logger.debug("{}: Updated Apps list, {} apps in list", host, allLines.size()); + remoteControllerWebSocket.updateAppList(allLines); + } catch (IOException e) { + logger.debug("{}: Cannot read apps file: {}", host, e.getMessage()); + } + } + } +} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvBindingConstants.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvBindingConstants.java old mode 100644 new mode 100755 index 85dbfb02eaab9..839086c435a95 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvBindingConstants.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvBindingConstants.java @@ -21,6 +21,7 @@ * * @author Pauli Anttila - Initial contribution * @author Arjan Mels - Added constants for websocket based remote controller + * @author Nick Waterton - Added artMode channels */ @NonNullByDefault public class SamsungTvBindingConstants { @@ -33,6 +34,7 @@ public class SamsungTvBindingConstants { public static final String KEY_CODE = "keyCode"; public static final String POWER = "power"; public static final String ART_MODE = "artMode"; + public static final String SET_ART_MODE = "setArtMode"; public static final String SOURCE_APP = "sourceApp"; // List of all media renderer thing channel id's @@ -51,4 +53,11 @@ public class SamsungTvBindingConstants { public static final String CHANNEL_NAME = "channelName"; public static final String BROWSER_URL = "url"; public static final String STOP_BROWSER = "stopBrowser"; + + // List of all artMode channels (Frame TV's only) + public static final String ART_IMAGE = "artImage"; + public static final String ART_LABEL = "artLabel"; + public static final String ART_JSON = "artJson"; + public static final String ART_BRIGHTNESS = "artBrightness"; + public static final String ART_COLOR_TEMPERATURE = "artColorTemperature"; } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvHandlerFactory.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvHandlerFactory.java old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvTlsTrustManagerProvider.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/SamsungTvTlsTrustManagerProvider.java old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/Utils.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/Utils.java new file mode 100755 index 0000000000000..fd7924c26893d --- /dev/null +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/Utils.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2010-2024 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.samsungtv.internal; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Base64; +import java.util.Optional; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.jupnp.model.meta.RemoteDevice; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +/** + * The {@link Utils} is a collection of static utilities + * + * @author Nick Waterton - Initial contribution + */ +@NonNullByDefault +public class Utils { + private static final Logger LOGGER = LoggerFactory.getLogger(Utils.class); + public static DocumentBuilderFactory factory = getDocumentBuilder(); + + private static DocumentBuilderFactory getDocumentBuilder() { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + try { + // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + } catch (ParserConfigurationException e) { + LOGGER.debug("XMLParser Configuration Error: {}", e.getMessage()); + } + return Optional.ofNullable(factory).orElse(DocumentBuilderFactory.newInstance()); + } + + /** + * Build {@link Document} from {@link String} which contains XML content. + * + * @param xml + * {@link String} which contains XML content. + * @return {@link Optional Document} or empty if convert has failed. + */ + public static Optional loadXMLFromString(String xml, String host) { + try { + return Optional.ofNullable(factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)))); + } catch (ParserConfigurationException | SAXException | IOException e) { + LOGGER.debug("{}: Error loading XML: {}", host, e.getMessage()); + } + return Optional.empty(); + } + + public static boolean isSoundChannel(String name) { + return (name.contains("Volume") || name.contains("Mute")); + } + + public static String b64encode(String str) { + return Base64.getUrlEncoder().encodeToString(str.getBytes()); + } + + public static String truncCmd(Command command) { + String cmd = command.toString(); + return (cmd.length() <= 80) ? cmd : cmd.substring(0, 80) + "..."; + } + + public static String getModelName(@Nullable RemoteDevice device) { + return Optional.ofNullable(device).map(a -> a.getDetails()).map(a -> a.getModelDetails()) + .map(a -> a.getModelName()).orElse(""); + } + + public static String getManufacturer(@Nullable RemoteDevice device) { + return Optional.ofNullable(device).map(a -> a.getDetails()).map(a -> a.getManufacturerDetails()) + .map(a -> a.getManufacturer()).orElse(""); + } + + public static String getFriendlyName(@Nullable RemoteDevice device) { + return Optional.ofNullable(device).map(a -> a.getDetails()).map(a -> a.getFriendlyName()).orElse(""); + } + + public static String getUdn(@Nullable RemoteDevice device) { + return Optional.ofNullable(device).map(a -> a.getIdentity()).map(a -> a.getUdn()) + .map(a -> a.getIdentifierString()).orElse(""); + } + + public static String getHost(@Nullable RemoteDevice device) { + return Optional.ofNullable(device).map(a -> a.getIdentity()).map(a -> a.getDescriptorURL()) + .map(a -> a.getHost()).orElse(""); + } + + public static String getType(@Nullable RemoteDevice device) { + return Optional.ofNullable(device).map(a -> a.getType()).map(a -> a.getType()).orElse(""); + } +} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WakeOnLanUtility.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WakeOnLanUtility.java old mode 100644 new mode 100755 index 29e3b4f24e462..35f74cd716544 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WakeOnLanUtility.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WakeOnLanUtility.java @@ -35,32 +35,44 @@ * * @author Arjan Mels - Initial contribution * @author Laurent Garnier - Use improvements from the LG webOS binding + * @author Nick Waterton - use single ip address as source per interface * */ @NonNullByDefault public class WakeOnLanUtility { private static final Logger LOGGER = LoggerFactory.getLogger(WakeOnLanUtility.class); - private static final Pattern MAC_REGEX = Pattern.compile("(([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})"); private static final int CMD_TIMEOUT_MS = 1000; + private static String host = ""; - private static final String COMMAND; - static { - String os = System.getProperty("os.name").toLowerCase(); - LOGGER.debug("os: {}", os); - if ((os.contains("win"))) { - COMMAND = "arp -a %s"; - } else if ((os.contains("mac"))) { - COMMAND = "arp %s"; - } else { // linux - if (checkIfLinuxCommandExists("arp")) { + /** + * Get os command to find MAC address + * + * @return os COMMAND + */ + public static String getCommand() { + String os = System.getProperty("os.name"); + String COMMAND = ""; + if (os != null) { + os = os.toLowerCase(); + LOGGER.debug("{}: os: {}", host, os); + if ((os.contains("win"))) { + COMMAND = "arp -a %s"; + } else if ((os.contains("mac"))) { COMMAND = "arp %s"; - } else if (checkIfLinuxCommandExists("arping")) { // typically OH provided docker image - COMMAND = "arping -r -c 1 -C 1 %s"; - } else { - COMMAND = ""; + } else { // linux + if (checkIfLinuxCommandExists("arp")) { + COMMAND = "arp %s"; + } else if (checkIfLinuxCommandExists("arping")) { // typically OH provided docker image + COMMAND = "arping -r -c 1 -C 1 %s"; + } else { + LOGGER.warn("{}: arping not installed", host); + } } + } else { + LOGGER.warn("{}: Unable to determine os", host); } + return COMMAND; } /** @@ -70,11 +82,14 @@ public class WakeOnLanUtility { * @return MAC address */ public static @Nullable String getMACAddress(String hostName) { + host = hostName; + String COMMAND = getCommand(); if (COMMAND.isEmpty()) { - LOGGER.debug("MAC address detection not possible. No command to identify MAC found."); + LOGGER.debug("{}: MAC address detection not possible. No command to identify MAC found.", hostName); return null; } + Pattern MAC_REGEX = Pattern.compile("(([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})"); String[] cmds = Stream.of(COMMAND.split(" ")).map(arg -> String.format(arg, hostName)).toArray(String[]::new); String response = ExecUtil.executeCommandLineAndWaitResponse(Duration.ofMillis(CMD_TIMEOUT_MS), cmds); String macAddress = null; @@ -91,9 +106,9 @@ public class WakeOnLanUtility { } } if (macAddress != null) { - LOGGER.debug("MAC address of host {} is {}", hostName, macAddress); + LOGGER.debug("{}: MAC address of host {} is {}", hostName, hostName, macAddress); } else { - LOGGER.debug("Problem executing command {} to retrieve MAC address for {}: {}", + LOGGER.debug("{}: Problem executing command {} to retrieve MAC address for {}: {}", hostName, String.format(COMMAND, hostName), hostName, response); } return macAddress; @@ -109,10 +124,10 @@ public static void sendWOLPacket(String macAddress) { try { Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - while (interfaces.hasMoreElements()) { + while (interfaces != null && interfaces.hasMoreElements()) { NetworkInterface networkInterface = interfaces.nextElement(); - if (networkInterface.isLoopback()) { - continue; // Do not want to use the loopback interface. + if (networkInterface.isLoopback() || !networkInterface.isUp()) { + continue; // Do not want to use the loopback or down interface. } for (InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { InetAddress broadcast = interfaceAddress.getBroadcast(); @@ -120,12 +135,14 @@ public static void sendWOLPacket(String macAddress) { continue; } + InetAddress local = interfaceAddress.getAddress(); DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, 9); try (DatagramSocket socket = new DatagramSocket()) { socket.send(packet); - LOGGER.trace("Sent WOL packet to {} {}", broadcast, macAddress); + LOGGER.trace("Sent WOL packet from {} to {} {}", local, broadcast, macAddress); + break; } catch (IOException e) { - LOGGER.warn("Problem sending WOL packet to {} {}", broadcast, macAddress); + LOGGER.warn("Problem sending WOL packet from {} to {} {}", local, broadcast, macAddress); } } } @@ -138,7 +155,7 @@ public static void sendWOLPacket(String macAddress) { /** * Create WOL UDP package: 6 bytes 0xff and then 16 times the 6 byte mac address repeated * - * @param macStr String representation of teh MAC address (either with : or -) + * @param macStr String representation of the MAC address (either with : or -) * @return byte array with the WOL package * @throws IllegalArgumentException */ @@ -171,7 +188,7 @@ private static boolean checkIfLinuxCommandExists(String cmd) { try { return 0 == Runtime.getRuntime().exec(String.format("which %s", cmd)).waitFor(); } catch (InterruptedException | IOException e) { - LOGGER.debug("Error trying to check if command {} exists: {}", cmd, e.getMessage()); + LOGGER.debug("{}: Error trying to check if command {} exists: {}", host, cmd, e.getMessage()); } return false; } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WolSend.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WolSend.java new file mode 100755 index 0000000000000..7baa573bf9b21 --- /dev/null +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/WolSend.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2010-2024 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.samsungtv.internal; + +import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; + +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler; +import org.openhab.binding.samsungtv.internal.service.RemoteControllerService; +import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link WolSend} is responsible for sending WOL packets and resending of commands + * Used by {@link SamsungTvHandler} + * + * @author Nick Waterton - Initial contribution + */ +@NonNullByDefault +public class WolSend { + + private static final int WOL_PACKET_RETRY_COUNT = 10; + private static final int WOL_SERVICE_CHECK_COUNT = 30; + + private final Logger logger = LoggerFactory.getLogger(WolSend.class); + + private String host = ""; + + int wolCount = 0; + String channel = POWER; + Command command = OnOffType.ON; + String macAddress = ""; + private Optional> wolJob = Optional.empty(); + SamsungTvHandler handler; + + public WolSend(SamsungTvHandler handler) { + this.handler = handler; + } + + /** + * Send multiple WOL packets spaced with 100ms intervals and resend command + * + * @param channel Channel to resend command on + * @param command Command to resend + * @return boolean true/false if WOL job started + */ + public boolean send(String channel, Command command) { + this.host = handler.host; + if (channel.equals(POWER) || channel.equals(ART_MODE)) { + if (OnOffType.ON.equals(command)) { + macAddress = handler.configuration.getMacAddress(); + if (macAddress.isBlank()) { + logger.debug("{}: Cannot send WOL packet, MAC address invalid: {}", host, macAddress); + return false; + } + this.channel = channel; + this.command = command; + if (channel.equals(ART_MODE) && !handler.getArtModeSupported()) { + logger.debug("{}: artMode is not yet detected on this TV - sending WOL anyway", host); + } + startWoljob(); + return true; + } else { + cancel(); + } + } + return false; + } + + private void startWoljob() { + wolJob.ifPresentOrElse(job -> { + if (job.isCancelled()) { + start(); + } else { + logger.debug("{}: WOL job already running", host); + } + }, () -> { + start(); + }); + } + + public void start() { + wolCount = 0; + wolJob = Optional.of( + handler.getScheduler().scheduleWithFixedDelay(this::wolCheckPeriodic, 0, 1000, TimeUnit.MILLISECONDS)); + } + + public synchronized void cancel() { + wolJob.ifPresent(job -> { + logger.debug("{}: cancelling WOL Job", host); + job.cancel(true); + }); + } + + private void sendWOL() { + logger.debug("{}: Send WOL packet to {}", host, macAddress); + + // send max 10 WOL packets with 100ms intervals + for (int i = 0; i < WOL_PACKET_RETRY_COUNT; i++) { + handler.getScheduler().schedule(() -> { + WakeOnLanUtility.sendWOLPacket(macAddress); + }, (i * 100), TimeUnit.MILLISECONDS); + } + } + + private void sendCommand(RemoteControllerService service) { + // send command in 2 seconds to allow time for connection to re-establish + logger.debug("{}: resend command {} to channel {} in 2 seconds...", host, command, channel); + handler.getScheduler().schedule(() -> { + service.handleCommand(channel, command); + }, 2000, TimeUnit.MILLISECONDS); + } + + private void wolCheckPeriodic() { + if (wolCount % 10 == 0) { + // resend WOL every 10 seconds + sendWOL(); + } + // after RemoteService up again to ensure state is properly set + Optional service = handler.findServiceInstance(RemoteControllerService.SERVICE_NAME); + service.ifPresent(s -> { + logger.debug("{}: RemoteControllerService found after {} attempts", host, wolCount); + // do not resend command if artMode command as TV wakes up in artMode + if (!channel.equals(ART_MODE)) { + sendCommand((RemoteControllerService) s); + } + cancel(); + }); + // cancel job + if (wolCount++ > WOL_SERVICE_CHECK_COUNT) { + logger.warn("{}: Service NOT found after {} attempts: stopping WOL attempts", host, wolCount); + cancel(); + handler.putOffline(); + } + } +} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/config/SamsungTvConfiguration.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/config/SamsungTvConfiguration.java old mode 100644 new mode 100755 index 2b1acf6c47f05..87ee4d74e7788 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/config/SamsungTvConfiguration.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/config/SamsungTvConfiguration.java @@ -12,15 +12,18 @@ */ package org.openhab.binding.samsungtv.internal.config; +import java.util.Optional; + import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler; /** - * Configuration class for {@link SamsungTvHandler}. + * Configuration class for {@link org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler}. * * @author Pauli Anttila - Initial contribution * @author Arjan Mels - Added MAC Address + * @author Nick Waterton - added Smartthings, subscription, refactoring */ + @NonNullByDefault({}) public class SamsungTvConfiguration { public static final String PROTOCOL = "protocol"; @@ -32,7 +35,10 @@ public class SamsungTvConfiguration { public static final String PORT = "port"; public static final String MAC_ADDRESS = "macAddress"; public static final String REFRESH_INTERVAL = "refreshInterval"; + public static final String SUBSCRIPTION = "subscription"; public static final String WEBSOCKET_TOKEN = "webSocketToken"; + public static final String SMARTTHINGS_API = "smartThingsApiKey"; + public static final String SMARTTHINGS_DEVICEID = "smartThingsDeviceId"; public static final int PORT_DEFAULT_LEGACY = 55000; public static final int PORT_DEFAULT_WEBSOCKET = 8001; public static final int PORT_DEFAULT_SECUREWEBSOCKET = 8002; @@ -42,5 +48,48 @@ public class SamsungTvConfiguration { public String macAddress; public int port; public int refreshInterval; - public String websocketToken; + public String webSocketToken; + public String smartThingsApiKey; + public String smartThingsDeviceId; + public boolean subscription; + + public boolean isWebsocketProtocol() { + return PROTOCOL_WEBSOCKET.equals(getProtocol()) || PROTOCOL_SECUREWEBSOCKET.equals(getProtocol()); + } + + public String getProtocol() { + return Optional.ofNullable(protocol).orElse(PROTOCOL_NONE); + } + + public String getHostName() { + return Optional.ofNullable(hostName).orElse(""); + } + + public String getMacAddress() { + return Optional.ofNullable(macAddress).filter(m -> m.length() == 17).orElse(""); + } + + public int getPort() { + return Optional.ofNullable(port).orElse(PORT_DEFAULT_LEGACY); + } + + public int getRefreshInterval() { + return Optional.ofNullable(refreshInterval).orElse(1000); + } + + public String getWebsocketToken() { + return Optional.ofNullable(webSocketToken).orElse(""); + } + + public String getSmartThingsApiKey() { + return Optional.ofNullable(smartThingsApiKey).orElse(""); + } + + public String getSmartThingsDeviceId() { + return Optional.ofNullable(smartThingsDeviceId).orElse(""); + } + + public boolean getSubscription() { + return Optional.ofNullable(subscription).orElse(false); + } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/discovery/SamsungTvDiscoveryParticipant.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/discovery/SamsungTvDiscoveryParticipant.java old mode 100644 new mode 100755 index 2a0b60a7f6b04..fa53624de5d53 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/discovery/SamsungTvDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/discovery/SamsungTvDiscoveryParticipant.java @@ -22,6 +22,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.jupnp.model.meta.RemoteDevice; +import org.openhab.binding.samsungtv.internal.Utils; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.upnp.UpnpDiscoveryParticipant; @@ -37,6 +38,7 @@ * * @author Pauli Anttila - Initial contribution * @author Arjan Mels - Changed to upnp.UpnpDiscoveryParticipant + * @author Nick Waterton - use Utils class */ @NonNullByDefault @Component @@ -53,53 +55,39 @@ public Set getSupportedThingTypeUIDs() { ThingUID uid = getThingUID(device); if (uid != null) { Map properties = new HashMap<>(); - properties.put(HOST_NAME, device.getIdentity().getDescriptorURL().getHost()); + properties.put(HOST_NAME, Utils.getHost(device)); DiscoveryResult result = DiscoveryResultBuilder.create(uid).withProperties(properties) .withRepresentationProperty(HOST_NAME).withLabel(getLabel(device)).build(); logger.debug("Created a DiscoveryResult for device '{}' with UDN '{}' and properties: {}", - device.getDetails().getModelDetails().getModelName(), - device.getIdentity().getUdn().getIdentifierString(), properties); + Utils.getModelName(device), Utils.getUdn(device), properties); return result; - } else { - return null; } + return null; } private String getLabel(RemoteDevice device) { - String label = "Samsung TV"; - try { - label = device.getDetails().getFriendlyName(); - } catch (Exception e) { - // ignore and use the default label - } - return label; + String label = Utils.getFriendlyName(device); + return label.isBlank() ? "Samsung TV" : label; } @Override public @Nullable ThingUID getThingUID(RemoteDevice device) { - if (device.getDetails() != null && device.getDetails().getManufacturerDetails() != null) { - String manufacturer = device.getDetails().getManufacturerDetails().getManufacturer(); - - if (manufacturer != null && manufacturer.toUpperCase().contains("SAMSUNG ELECTRONICS")) { - // One Samsung TV contains several UPnP devices. - // Create unique Samsung TV thing for every MediaRenderer - // device and ignore rest of the UPnP devices. + if (Utils.getManufacturer(device).toUpperCase().contains("SAMSUNG ELECTRONICS")) { + // One Samsung TV contains several UPnP devices. + // Create unique Samsung TV thing for every MediaRenderer + // device and ignore rest of the UPnP devices. + // use MediaRenderer udn for ThingID. - if (device.getType() != null && "MediaRenderer".equals(device.getType().getType())) { - // UDN shouldn't contain '-' characters. - String udn = device.getIdentity().getUdn().getIdentifierString().replace("-", "_"); - - if (logger.isDebugEnabled()) { - String modelName = device.getDetails().getModelDetails().getModelName(); - String friendlyName = device.getDetails().getFriendlyName(); - logger.debug("Retrieved Thing UID for a Samsung TV '{}' model '{}' thing with UDN '{}'", - friendlyName, modelName, udn); - } - - return new ThingUID(SAMSUNG_TV_THING_TYPE, udn); + if ("MediaRenderer".equals(Utils.getType(device))) { + String udn = Utils.getUdn(device); + if (logger.isDebugEnabled()) { + logger.debug("Retrieved Thing UID for a Samsung TV '{}' model '{}' thing with UDN '{}'", + Utils.getFriendlyName(device), Utils.getModelName(device), udn); } + + return new ThingUID(SAMSUNG_TV_THING_TYPE, udn); } } return null; diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/handler/SamsungTvHandler.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/handler/SamsungTvHandler.java old mode 100644 new mode 100755 index db4c95e4278d3..c94c3c9f622a9 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/handler/SamsungTvHandler.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/handler/SamsungTvHandler.java @@ -13,10 +13,19 @@ package org.openhab.binding.samsungtv.internal.handler; import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; - -import java.util.Map; +import static org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration.*; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -28,12 +37,19 @@ import org.jupnp.model.meta.RemoteDevice; import org.jupnp.registry.Registry; import org.jupnp.registry.RegistryListener; +import org.openhab.binding.samsungtv.internal.Utils; import org.openhab.binding.samsungtv.internal.WakeOnLanUtility; +import org.openhab.binding.samsungtv.internal.WolSend; import org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration; +import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerException; +import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerLegacy; +import org.openhab.binding.samsungtv.internal.service.MainTVServerService; +import org.openhab.binding.samsungtv.internal.service.MediaRendererService; import org.openhab.binding.samsungtv.internal.service.RemoteControllerService; -import org.openhab.binding.samsungtv.internal.service.ServiceFactory; -import org.openhab.binding.samsungtv.internal.service.api.EventListener; +import org.openhab.binding.samsungtv.internal.service.SmartThingsApiService; import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.io.net.http.HttpUtil; import org.openhab.core.io.net.http.WebSocketFactory; import org.openhab.core.io.transport.upnp.UpnpIOService; import org.openhab.core.library.types.OnOffType; @@ -46,9 +62,13 @@ import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + /** * The {@link SamsungTvHandler} is responsible for handling commands, which are * sent to one of the channels. @@ -57,12 +77,16 @@ * @author Martin van Wingerden - Some changes for non-UPnP configured devices * @author Arjan Mels - Remove RegistryListener, manually create RemoteService in all circumstances, add sending of WOL * package to power on TV + * @author Nick Waterton - Improve Frame TV handling and some refactoring */ @NonNullByDefault -public class SamsungTvHandler extends BaseThingHandler implements RegistryListener, EventListener { +public class SamsungTvHandler extends BaseThingHandler implements RegistryListener { + + /** Path for the information endpoint (note the final slash!) */ + private static final String HTTP_ENDPOINT_V2 = "/api/v2/"; - private static final int WOL_PACKET_RETRY_COUNT = 10; - private static final int WOL_SERVICE_CHECK_COUNT = 30; + // common Samsung TV remote control ports + private final static List PORTS = List.of(55000, 1515, 7001, 15500); private final Logger logger = LoggerFactory.getLogger(SamsungTvHandler.class); @@ -70,9 +94,11 @@ public class SamsungTvHandler extends BaseThingHandler implements RegistryListen private final UpnpService upnpService; private final WebSocketFactory webSocketFactory; - private SamsungTvConfiguration configuration; + public SamsungTvConfiguration configuration; - private @Nullable String upnpUDN = null; + public String host = ""; + private String modelName = ""; + public int artApiVersion = 0; /* Samsung TV services */ private final Set services = new CopyOnWriteArraySet<>(); @@ -81,153 +107,484 @@ public class SamsungTvHandler extends BaseThingHandler implements RegistryListen private boolean powerState = false; /* Store if art mode is supported to be able to skip switching power state to ON during initialization */ - boolean artModeIsSupported = false; + public boolean artModeSupported = false; + /* Art Mode on TV's >= 2022 is not properly supported - need workarounds for power */ + public boolean artMode2022 = false; + /* Is binding initialized? */ + public boolean initialized = false; + + private Optional> pollingJob = Optional.empty(); + private WolSend wolTask = new WolSend(this); + + /** Description of the json returned for the information endpoint */ + @NonNullByDefault({}) + public class TVProperties { + class Device { + boolean FrameTVSupport; + boolean GamePadSupport; + boolean ImeSyncedSupport; + String OS; + String PowerState; + boolean TokenAuthSupport; + boolean VoiceSupport; + String countryCode; + String description; + String firmwareVersion; + String model; + String modelName; + String name; + String networkType; + String resolution; + String id; + String wifiMac; + } + + Device device; + String isSupport; + + public boolean getFrameTVSupport() { + return Optional.ofNullable(device).map(a -> a.FrameTVSupport).orElse(false); + } + + public boolean getTokenAuthSupport() { + return Optional.ofNullable(device).map(a -> a.TokenAuthSupport).orElse(false); + } + + public String getPowerState() { + if (!getOS().isBlank()) { + return Optional.ofNullable(device).map(a -> a.PowerState).orElse("on"); + } + return "off"; + } - private @Nullable ScheduledFuture pollingJob; + public String getOS() { + return Optional.ofNullable(device).map(a -> a.OS).orElse(""); + } + + public String getWifiMac() { + return Optional.ofNullable(device).map(a -> a.wifiMac).filter(m -> m.length() == 17).orElse(""); + } + + public String getModel() { + return Optional.ofNullable(device).map(a -> a.model).orElse(""); + } + + public String getModelName() { + return Optional.ofNullable(device).map(a -> a.modelName).orElse(""); + } + } public SamsungTvHandler(Thing thing, UpnpIOService upnpIOService, UpnpService upnpService, WebSocketFactory webSocketFactory) { super(thing); - - logger.debug("Create a Samsung TV Handler for thing '{}'", getThing().getUID()); - this.upnpIOService = upnpIOService; this.upnpService = upnpService; this.webSocketFactory = webSocketFactory; this.configuration = getConfigAs(SamsungTvConfiguration.class); + this.host = configuration.getHostName(); + logger.debug("{}: Create a Samsung TV Handler for thing '{}'", host, getThing().getUID()); } - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - logger.debug("Received channel: {}, command: {}", channelUID, command); + /** + * For Modern TVs get configuration, with 500 ms timeout + * + */ + public class FetchTVProperties implements Callable> { + public Optional call() throws Exception { + logger.trace("{}: getting TV properties", host); + Optional properties = Optional.empty(); + try { + URI uri = new URI("http", null, host, PORT_DEFAULT_WEBSOCKET, HTTP_ENDPOINT_V2, null, null); + // @Nullable + String response = HttpUtil.executeUrl("GET", uri.toURL().toString(), 500); + properties = Optional.ofNullable(new Gson().fromJson(response, TVProperties.class)); + } catch (JsonSyntaxException | URISyntaxException | IOException e) { + logger.warn("{}: Cannot connect to TV: {}", host, e.getMessage()); + properties = Optional.empty(); + } + return properties; + } + } - String channel = channelUID.getId(); + /** + * For Modern TVs get configuration, with time delay, and retry + * + * @param ms int delay in milliseconds + * @param retryCount int number of retries before giving up + * @return TVProperties + */ + public TVProperties fetchTVProperties(int ms, int retryCount) { + ScheduledFuture> future = scheduler.schedule(new FetchTVProperties(), ms, + TimeUnit.MILLISECONDS); + try { + Optional properties = future.get(); + while (retryCount-- >= 0) { + if (properties.isPresent()) { + return properties.get(); + } else if (retryCount > 0) { + logger.warn("{}: Cannot get TVProperties - Retry: {}", host, retryCount); + return fetchTVProperties(1000, retryCount); + } + } + } catch (InterruptedException | ExecutionException e) { + logger.warn("{}: Cannot get TVProperties: {}", host, e.getMessage()); + } + logger.warn("{}: Cannot get TVProperties, return Empty properties", host); + return new TVProperties(); + } - // if power on command try WOL for good measure: - if ((channel.equals(POWER) || channel.equals(ART_MODE)) && OnOffType.ON.equals(command)) { - sendWOLandResendCommand(channel, command); + /** + * Update WOL MAC address + * Discover the type of remote control service the TV supports. + * update artModeSupported and PowerState + * Update the configuration with results + * + */ + private void discoverConfiguration() { + /* Check if configuration should be updated */ + configuration = getConfigAs(SamsungTvConfiguration.class); + host = configuration.getHostName(); + switch (configuration.getProtocol()) { + case PROTOCOL_NONE: + if (configuration.getMacAddress().isBlank()) { + String macAddress = WakeOnLanUtility.getMACAddress(host); + if (macAddress != null) { + putConfig(MAC_ADDRESS, macAddress); + } + } + TVProperties properties = fetchTVProperties(0, 0); + if ("Tizen".equals(properties.getOS())) { + if (properties.getTokenAuthSupport()) { + putConfig(PROTOCOL, PROTOCOL_SECUREWEBSOCKET); + putConfig(PORT, PORT_DEFAULT_SECUREWEBSOCKET); + } else { + putConfig(PROTOCOL, PROTOCOL_WEBSOCKET); + putConfig(PORT, PORT_DEFAULT_WEBSOCKET); + } + if ((configuration.getMacAddress().isBlank()) && !properties.getWifiMac().isBlank()) { + putConfig(MAC_ADDRESS, properties.getWifiMac()); + } + updateSettings(properties); + break; + } + + initialized = true; + for (int port : PORTS) { + try { + RemoteControllerLegacy remoteController = new RemoteControllerLegacy(host, port, "openHAB", + "openHAB"); + remoteController.openConnection(); + remoteController.close(); + putConfig(PROTOCOL, SamsungTvConfiguration.PROTOCOL_LEGACY); + putConfig(PORT, port); + setPowerState(true); + break; + } catch (RemoteControllerException e) { + // ignore error + } + } + break; + case PROTOCOL_WEBSOCKET: + case PROTOCOL_SECUREWEBSOCKET: + initializeConfig(); + if (!initialized) { + logger.warn("{}: TV binding is not yet Initialized", host); + } + break; + case PROTOCOL_LEGACY: + initialized = true; + break; + } + showConfiguration(); + } + + public void initializeConfig() { + if (!initialized) { + TVProperties properties = fetchTVProperties(0, 0); + if ("on".equals(properties.getPowerState())) { + updateSettings(properties); + } + } + } + + public void updateSettings(TVProperties properties) { + setPowerState("on".equals(properties.getPowerState())); + setModelName(properties.getModelName()); + int year = Integer.parseInt(properties.getModel().substring(0, 2)); + if (properties.getFrameTVSupport() && year >= 22) { + logger.warn("{}: Art Mode MAY NOT BE SUPPORTED on Frame TV's after 2021 model year", host); + setArtMode2022(true); + artApiVersion = 1; } + setArtModeSupported(properties.getFrameTVSupport() && year < 22); + logger.debug("{}: Updated artModeSupported: {} PowerState: {}({}) artMode2022: {}", host, getArtModeSupported(), + getPowerState(), properties.getPowerState(), getArtMode2022()); + initialized = true; + } + + public void showConfiguration() { + logger.debug("{}: Configuration: {}, port: {}, token: {}, MAC: {}, subscription: {}", host, + configuration.getProtocol(), configuration.getPort(), configuration.getWebsocketToken(), + configuration.getMacAddress(), configuration.getSubscription()); + if (configuration.isWebsocketProtocol()) { + if (configuration.getSmartThingsApiKey().isBlank()) { + logger.debug("{}: SmartThings disabled", host); + } else { + logger.debug("{}: SmartThings enabled, device id: {}", host, configuration.getSmartThingsDeviceId()); + } + } + } + + /** + * get PowerState from TVProperties + * Note: Series 7 TV's do not have the PowerState value + * + * @return String giving power state (TV can be on or standby, off if unreachable) + */ + public String fetchPowerState() { + logger.trace("{}: fetching TV Power State", host); + TVProperties properties = fetchTVProperties(0, 2); + String PowerState = properties.getPowerState(); + setPowerState("on".equals(PowerState)); + logger.debug("{}: PowerState is: {}", host, PowerState); + return PowerState; + } + + public boolean handleCommand(String channel, Command command, int ms) { + scheduler.schedule(() -> { + handleCommand(channel, command); + }, ms, TimeUnit.MILLISECONDS); + return true; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("{}: Received channel: {}, command: {}", host, channelUID, Utils.truncCmd(command)); + handleCommand(channelUID.getId(), command); + } + + public void handleCommand(String channel, Command command) { + logger.trace("{}: Received: {}, command: {}", host, channel, Utils.truncCmd(command)); // Delegate command to correct service for (SamsungTvService service : services) { - for (String s : service.getSupportedChannelNames()) { + for (String s : service.getSupportedChannelNames(command == RefreshType.REFRESH)) { if (channel.equals(s)) { - service.handleCommand(channel, command); - return; + if (service.handleCommand(channel, command)) { + return; + } } } } - - logger.warn("Channel '{}' not supported", channelUID); + // if power on/artmode on command try WOL if command failed: + if (!wolTask.send(channel, command)) { + if (getThing().getStatus() != ThingStatus.ONLINE) { + logger.warn("{}: TV is {}", host, getThing().getStatus()); + } else { + logger.warn("{}: Channel '{}' not connected/supported", host, channel); + } + } } @Override public void channelLinked(ChannelUID channelUID) { - logger.trace("channelLinked: {}", channelUID); - - updateState(POWER, OnOffType.from(getPowerState())); + logger.trace("{}: channelLinked: {}", host, channelUID); + if (POWER.equals(channelUID.getId())) { + valueReceived(POWER, OnOffType.from(getPowerState())); + } + services.stream().forEach(a -> a.clearCache()); + if (Arrays.asList(ART_COLOR_TEMPERATURE, ART_IMAGE).contains(channelUID.getId())) { + // refresh channel as it's not polled + services.stream().filter(a -> a.getServiceName().equals(RemoteControllerService.SERVICE_NAME)) + .map(a -> a.handleCommand(channelUID.getId(), RefreshType.REFRESH)); + } + } - for (SamsungTvService service : services) { - service.clearCache(); + public void setModelName(String modelName) { + if (!modelName.isBlank()) { + this.modelName = modelName; } } - private synchronized void setPowerState(boolean state) { + public String getModelName() { + return modelName; + } + + public synchronized void setPowerState(boolean state) { powerState = state; + logger.trace("{}: PowerState set to: {}", host, powerState ? "on" : "off"); } - private synchronized boolean getPowerState() { + public boolean getPowerState() { return powerState; } + public void setArtMode2022(boolean artmode) { + artMode2022 = artmode; + } + + public boolean getArtMode2022() { + return artMode2022; + } + + public boolean getArtModeSupported() { + return artModeSupported; + } + + public synchronized void setArtModeSupported(boolean artmode) { + if (!artModeSupported && artmode) { + logger.debug("{}: ArtMode Enabled", host); + } + artModeSupported = artmode; + } + @Override public void initialize() { updateStatus(ThingStatus.UNKNOWN); - logger.debug("Initializing Samsung TV handler for uid '{}'", getThing().getUID()); + logger.debug("{}: Initializing Samsung TV handler for uid '{}'", host, getThing().getUID()); + if (host.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "host ip address or name is blank"); + return; + } - configuration = getConfigAs(SamsungTvConfiguration.class); + // note this can take up to 500ms to return if TV is off + discoverConfiguration(); upnpService.getRegistry().addListener(this); checkAndCreateServices(); + } - logger.debug("Start refresh task, interval={}", configuration.refreshInterval); - pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refreshInterval, - TimeUnit.MILLISECONDS); + /** + * Start polling job with initial delay of 10 seconds if websocket protocol is selected + * + */ + private void startPolling() { + int interval = configuration.getRefreshInterval(); + int delay = configuration.isWebsocketProtocol() ? 10000 : 0; + if (pollingJob.map(job -> (job.isCancelled())).orElse(true)) { + logger.debug("{}: Start refresh task, interval={}", host, interval); + pollingJob = Optional + .of(scheduler.scheduleWithFixedDelay(this::poll, delay, interval, TimeUnit.MILLISECONDS)); + } + } + + private void stopPolling() { + pollingJob.ifPresent(job -> job.cancel(true)); + pollingJob = Optional.empty(); } @Override public void dispose() { - logger.debug("Disposing SamsungTvHandler"); + logger.debug("{}: Disposing SamsungTvHandler", host); + stopPolling(); + wolTask.cancel(); + setArtMode2022(false); + setArtModeSupported(false); + artApiVersion = 0; + stopServices(); + services.clear(); + upnpService.getRegistry().removeListener(this); + } - if (pollingJob != null) { - if (!pollingJob.isCancelled()) { - pollingJob.cancel(true); + private synchronized void stopServices() { + stopPolling(); + if (!services.isEmpty()) { + if (isFrame2022()) { + logger.debug("{}: Shutdown all Samsung services except RemoteControllerService", host); + services.stream().forEach(a -> stopService(a)); + } else { + logger.debug("{}: Shutdown all Samsung services", host); + services.stream().forEach(a -> stopService(a)); + services.clear(); } - pollingJob = null; } + } - upnpService.getRegistry().removeListener(this); - shutdown(); + private synchronized void shutdown() { + stopServices(); putOffline(); } - private void shutdown() { - logger.debug("Shutdown all Samsung services"); - for (SamsungTvService service : services) { - stopService(service); + public synchronized void putOnline() { + if (getThing().getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + startPolling(); + if (!getArtModeSupported()) { + if (getArtMode2022()) { + handleCommand(SET_ART_MODE, OnOffType.ON, 4000); + } else if (configuration.isWebsocketProtocol()) { + // if TV is registered to SmartThings it wakes up regularly (every 5 minutes or so), even if it's in + // standby, so check the power state locally to see if it's actually on + fetchPowerState(); + valueReceived(POWER, OnOffType.from(getPowerState())); + } else { + valueReceived(POWER, OnOffType.ON); + } + } + logger.debug("{}: TV is {}", host, getThing().getStatus()); } - services.clear(); } - private synchronized void putOnline() { - setPowerState(true); - updateStatus(ThingStatus.ONLINE); - - if (!artModeIsSupported) { - updateState(POWER, OnOffType.ON); + public synchronized void putOffline() { + if (getThing().getStatus() != ThingStatus.OFFLINE) { + stopPolling(); + valueReceived(ART_MODE, OnOffType.OFF); + valueReceived(POWER, OnOffType.OFF); + if (getArtMode2022()) { + valueReceived(SET_ART_MODE, OnOffType.OFF); + } + valueReceived(ART_IMAGE, UnDefType.NULL); + valueReceived(ART_LABEL, new StringType("")); + valueReceived(SOURCE_APP, new StringType("")); + updateStatus(ThingStatus.OFFLINE); + logger.debug("{}: TV is {}", host, getThing().getStatus()); } } - private synchronized void putOffline() { - setPowerState(false); - updateStatus(ThingStatus.OFFLINE); - updateState(ART_MODE, OnOffType.OFF); - updateState(POWER, OnOffType.OFF); - updateState(SOURCE_APP, new StringType("")); + public boolean isChannelLinked(String ch) { + return isLinked(ch); + } + + private boolean isDuplicateChannel(String channel) { + // Avoid redundant REFRESH commands when 2 channels are linked to the same action request + return (channel.equals(SOURCE_ID) && isLinked(SOURCE_NAME)) + || (channel.equals(CHANNEL_NAME) && isLinked(PROGRAM_TITLE)); } private void poll() { - for (SamsungTvService service : services) { - for (String channel : service.getSupportedChannelNames()) { - if (isLinked(channel)) { - // Avoid redundant REFRESH commands when 2 channels are linked to the same UPnP action request - if ((channel.equals(SOURCE_ID) && isLinked(SOURCE_NAME)) - || (channel.equals(CHANNEL_NAME) && isLinked(PROGRAM_TITLE))) { - continue; - } - service.handleCommand(channel, RefreshType.REFRESH); - } + try { + // Skip channels if service is not connected/started + services.stream().filter(service -> service.checkConnection()) + .forEach(service -> service.getSupportedChannelNames(true).stream() + .filter(channel -> isLinked(channel) && !isDuplicateChannel(channel)) + .forEach(channel -> service.handleCommand(channel, RefreshType.REFRESH))); + } catch (Exception e) { + if (logger.isTraceEnabled()) { + logger.trace("{}: Polling Job exception: ", host, e); + } else { + logger.debug("{}: Polling Job exception: {}", host, e.getMessage()); } } } - @Override public synchronized void valueReceived(String variable, State value) { - logger.debug("Received value '{}':'{}' for thing '{}'", variable, value, this.getThing().getUID()); + logger.debug("{}: Received value '{}':'{}' for thing '{}'", host, variable, value, this.getThing().getUID()); if (POWER.equals(variable)) { setPowerState(OnOffType.ON.equals(value)); - } else if (ART_MODE.equals(variable)) { - artModeIsSupported = true; } updateState(variable, value); } - @Override public void reportError(ThingStatusDetail statusDetail, @Nullable String message, @Nullable Throwable e) { - logger.debug("Error was reported: {}", message, e); + if (logger.isTraceEnabled()) { + logger.trace("{}: Error was reported: {}", host, message, e); + } else { + logger.debug("{}: Error was reported: {}, {}", host, message, (e != null) ? e.getMessage() : ""); + } updateStatus(ThingStatus.OFFLINE, statusDetail, message); } @@ -235,147 +592,143 @@ public void reportError(ThingStatusDetail statusDetail, @Nullable String message * One Samsung TV contains several UPnP devices. Samsung TV is discovered by * Media Renderer UPnP device. This function tries to find another UPnP * devices related to same Samsung TV and create handler for those. + * Also attempts to create websocket services if protocol is set to websocket + * And at least one UPNP service is discovered + * Smartthings service is also started if PAT (Api key) is entered */ private void checkAndCreateServices() { - logger.debug("Check and create missing UPnP services"); + logger.debug("{}: Check and create missing services", host); boolean isOnline = false; + // UPnP services for (Device device : upnpService.getRegistry().getDevices()) { - if (createService((RemoteDevice) device)) { - isOnline = true; + RemoteDevice rdevice = (RemoteDevice) device; + if (host.equals(Utils.getHost(rdevice))) { + setModelName(Utils.getModelName(rdevice)); + isOnline = createService(Utils.getType(rdevice), Utils.getUdn(rdevice)) || isOnline; + } + } + + // Websocket services and Smartthings service + if ((isOnline | getArtMode2022()) && configuration.isWebsocketProtocol()) { + createService(RemoteControllerService.SERVICE_NAME, ""); + if (!configuration.getSmartThingsApiKey().isBlank()) { + createService(SmartThingsApiService.SERVICE_NAME, ""); } } if (isOnline) { - logger.debug("Device was online"); putOnline(); } else { - logger.debug("Device was NOT online"); putOffline(); } - - checkCreateManualConnection(); } - private synchronized boolean createService(RemoteDevice device) { - if (configuration.hostName != null - && configuration.hostName.equals(device.getIdentity().getDescriptorURL().getHost())) { - String modelName = device.getDetails().getModelDetails().getModelName(); - String udn = device.getIdentity().getUdn().getIdentifierString(); - String type = device.getType().getType(); - - SamsungTvService existingService = findServiceInstance(type); + /** + * Create or restart existing Samsung TV service. + * udn is used to determine whether to start upnp service or websocket + * + * @param type + * @param udn + * @param modelName + * @return true if service restated or created, false otherwise + */ + private synchronized boolean createService(String type, String udn) { - if (existingService == null || !existingService.isUpnp()) { - SamsungTvService newService = ServiceFactory.createService(type, upnpIOService, udn, - configuration.hostName, configuration.port); + Optional service = findServiceInstance(type); - if (newService != null) { - if (existingService != null) { - stopService(existingService); - startService(newService); - logger.debug("Restarting service in UPnP mode for: {}, {} ({})", modelName, type, udn); - } else { - startService(newService); - logger.debug("Started service for: {}, {} ({})", modelName, type, udn); - } - } else { - logger.trace("Skipping unknown UPnP service: {}, {} ({})", modelName, type, udn); - } - } else { - logger.debug("Service rediscovered, clearing caches: {}, {} ({})", modelName, type, udn); - existingService.clearCache(); + if (service.isPresent()) { + if ((!udn.isBlank() && service.get().isUpnp()) || (udn.isBlank() && !service.get().isUpnp())) { + logger.debug("{}: Service rediscovered, clearing caches: {}, {} ({})", host, getModelName(), type, udn); + service.get().clearCache(); + return true; } + return false; + } + + service = createNewService(type, udn); + if (service.isPresent()) { + startService(service.get()); + logger.debug("{}: Started service for: {}, {} ({})", host, getModelName(), type, udn); return true; } + logger.trace("{}: Skipping unknown service: {}, {} ({})", host, modelName, type, udn); return false; } - private @Nullable SamsungTvService findServiceInstance(String serviceName) { - Class cl = ServiceFactory.getClassByServiceName(serviceName); - - for (SamsungTvService service : services) { - if (service.getClass() == cl) { - return service; - } + /** + * Create Samsung TV service. + * udn is used to determine whether to start upnp service or websocket + * + * @param type + * @param udn + * @return service or null + */ + private synchronized Optional createNewService(String type, String udn) { + Optional service = Optional.empty(); + + switch (type) { + case MainTVServerService.SERVICE_NAME: + service = Optional.of(new MainTVServerService(upnpIOService, udn, host, this)); + break; + case MediaRendererService.SERVICE_NAME: + service = Optional.of(new MediaRendererService(upnpIOService, udn, host, this)); + break; + case RemoteControllerService.SERVICE_NAME: + try { + if (configuration.isWebsocketProtocol() && !udn.isEmpty()) { + throw new RemoteControllerException("config is websocket - ignoring UPNP service"); + } + service = Optional + .of(new RemoteControllerService(host, configuration.getPort(), !udn.isEmpty(), this)); + } catch (RemoteControllerException e) { + logger.warn("{}: Not creating remote controller service: {}", host, e.getMessage()); + } + break; + case SmartThingsApiService.SERVICE_NAME: + service = Optional.of(new SmartThingsApiService(host, this)); + break; } - return null; + return service; } - private synchronized void checkCreateManualConnection() { - try { - // create remote service manually if it does not yet exist - - RemoteControllerService service = (RemoteControllerService) findServiceInstance( - RemoteControllerService.SERVICE_NAME); - if (service == null) { - service = RemoteControllerService.createNonUpnpService(configuration.hostName, configuration.port); - startService(service); - } else { - // open connection again if needed - if (!service.checkConnection()) { - service.start(); - } - } - } catch (RuntimeException e) { - logger.warn("Catching all exceptions because otherwise the thread would silently fail", e); - } + public synchronized Optional findServiceInstance(String serviceName) { + return services.stream().filter(a -> a.getServiceName().equals(serviceName)).findFirst(); } private synchronized void startService(SamsungTvService service) { - service.addEventListener(this); service.start(); services.add(service); } private synchronized void stopService(SamsungTvService service) { + if (isFrame2022() && service.getServiceName().equals(RemoteControllerService.SERVICE_NAME)) { + // don't stop the remoteController service on 2022 frame TV's + logger.debug("{}: not stopping: {}", host, service.getServiceName()); + return; + } service.stop(); - service.removeEventListener(this); services.remove(service); } @Override public void remoteDeviceAdded(@Nullable Registry registry, @Nullable RemoteDevice device) { - if (configuration.hostName != null && device != null && device.getIdentity() != null - && device.getIdentity().getDescriptorURL() != null - && configuration.hostName.equals(device.getIdentity().getDescriptorURL().getHost()) - && device.getType() != null) { - logger.debug("remoteDeviceAdded: {}, {}", device.getType().getType(), - device.getIdentity().getDescriptorURL()); - - /* Check if configuration should be updated */ - if (configuration.macAddress == null || configuration.macAddress.trim().isEmpty()) { - String macAddress = WakeOnLanUtility.getMACAddress(configuration.hostName); - if (macAddress != null) { - putConfig(SamsungTvConfiguration.MAC_ADDRESS, macAddress); - logger.debug("remoteDeviceAdded, macAddress: {}", macAddress); - } - } - if (SamsungTvConfiguration.PROTOCOL_NONE.equals(configuration.protocol)) { - Map properties = RemoteControllerService.discover(configuration.hostName); - for (Map.Entry property : properties.entrySet()) { - putConfig(property.getKey(), property.getValue()); - logger.debug("remoteDeviceAdded, {}: {}", property.getKey(), property.getValue()); - } - } - upnpUDN = device.getIdentity().getUdn().getIdentifierString().replace("-", "_"); - logger.debug("remoteDeviceAdded, upnpUDN={}", upnpUDN); + if (device != null && host.equals(Utils.getHost(device))) { + logger.debug("{}: remoteDeviceAdded: {}, {}, upnpUDN={}", host, Utils.getType(device), + device.getIdentity().getDescriptorURL(), Utils.getUdn(device)); + initializeConfig(); checkAndCreateServices(); } } @Override public void remoteDeviceRemoved(@Nullable Registry registry, @Nullable RemoteDevice device) { - if (device == null) { - return; - } - String udn = device.getIdentity().getUdn().getIdentifierString().replace("-", "_"); - if (udn.equals(upnpUDN)) { - logger.debug("Device removed: udn={}", upnpUDN); - shutdown(); - putOffline(); - checkCreateManualConnection(); + if (device != null && host.equals(Utils.getHost(device))) { + if (services.stream().anyMatch(s -> s.getServiceName().equals(Utils.getType(device)))) { + logger.debug("{}: Device removed: {}, udn={}", host, Utils.getType(device), Utils.getUdn(device)); + shutdown(); + } } } @@ -408,70 +761,30 @@ public void beforeShutdown(@Nullable Registry registry) { public void afterShutdown() { } - /** - * Send multiple WOL packets spaced with 100ms intervals and resend command - * - * @param channel Channel to resend command on - * @param command Command to resend - */ - private void sendWOLandResendCommand(String channel, Command command) { - if (configuration.macAddress == null || configuration.macAddress.isEmpty()) { - logger.warn("Cannot send WOL packet to {} MAC address unknown", configuration.hostName); - return; - } else { - logger.info("Send WOL packet to {} ({})", configuration.hostName, configuration.macAddress); - - // send max 10 WOL packets with 100ms intervals - scheduler.schedule(new Runnable() { - int count = 0; - - @Override - public void run() { - count++; - if (count < WOL_PACKET_RETRY_COUNT) { - WakeOnLanUtility.sendWOLPacket(configuration.macAddress); - scheduler.schedule(this, 100, TimeUnit.MILLISECONDS); - } - } - }, 1, TimeUnit.MILLISECONDS); - - // after RemoteService up again to ensure state is properly set - scheduler.schedule(new Runnable() { - int count = 0; - - @Override - public void run() { - count++; - if (count < WOL_SERVICE_CHECK_COUNT) { - RemoteControllerService service = (RemoteControllerService) findServiceInstance( - RemoteControllerService.SERVICE_NAME); - if (service != null) { - logger.info("Service found after {} attempts: resend command {} to channel {}", count, - command, channel); - service.handleCommand(channel, command); - } else { - scheduler.schedule(this, 1000, TimeUnit.MILLISECONDS); - } - } else { - logger.info("Service NOT found after {} attempts", count); - } - } - }, 1000, TimeUnit.MILLISECONDS); - } + public boolean isFrame2022() { + return getArtMode2022() || (getArtModeSupported() && artApiVersion >= 1); + } + + public void setOffline() { + // schedule this in the future to allow calling service to return immediately + scheduler.submit(this::shutdown); } - @Override public void putConfig(@Nullable String key, @Nullable Object value) { - getConfig().put(key, value); - configuration = getConfigAs(SamsungTvConfiguration.class); + if (key != null && value != null) { + getConfig().put(key, value); + Configuration config = editConfiguration(); + config.put(key, value); + updateConfiguration(config); + logger.debug("{}: Updated Configuration {}:{}", host, key, value); + configuration = getConfigAs(SamsungTvConfiguration.class); + } } - @Override - public Object getConfig(@Nullable String key) { - return getConfig().get(key); + public ScheduledExecutorService getScheduler() { + return scheduler; } - @Override public WebSocketFactory getWebSocketFactory() { return webSocketFactory; } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KeyCode.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KeyCode.java old mode 100644 new mode 100755 index 7701c76c29a9b..a946742911b92 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KeyCode.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KeyCode.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.samsungtv.internal.protocol; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * The {@link KeyCode} presents all available key codes of Samsung TV. * @@ -21,7 +23,9 @@ * * * @author Pauli Anttila - Initial contribution + * @author Nick Waterton - added KEY_AMBIENT, KEY_BT_VOICE */ +@NonNullByDefault public enum KeyCode { KEY_0, @@ -42,6 +46,7 @@ public enum KeyCode { KEY_AD, KEY_ADDDEL, KEY_ALT_MHP, + KEY_AMBIENT, KEY_ANGLE, KEY_ANTENA, KEY_ANYNET, @@ -82,6 +87,7 @@ public enum KeyCode { KEY_AV3, KEY_BACK_MHP, KEY_BOOKMARK, + KEY_BT_VOICE, KEY_CALLER_ID, KEY_CAPTION, KEY_CATV_MODE, @@ -190,6 +196,7 @@ public enum KeyCode { KEY_MOVIE1, KEY_MS, KEY_MTS, + KEY_MULTI_VIEW, KEY_MUTE, KEY_NINE_SEPERATE, KEY_OPEN, @@ -272,7 +279,7 @@ public enum KeyCode { private final String value; KeyCode() { - value = null; + value = ""; } KeyCode(String value) { @@ -284,7 +291,7 @@ public enum KeyCode { } public String getValue() { - if (value == null) { + if ("".equals(value)) { return this.name(); } return value; diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KnownAppId.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KnownAppId.java new file mode 100755 index 0000000000000..4ce1215799b9d --- /dev/null +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/KnownAppId.java @@ -0,0 +1,392 @@ +/** + * Copyright (c) 2010-2024 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.samsungtv.internal.protocol; + +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link KnownAppId} lists all the known app IDs for Samsung TV's + * + * + * @author Nick Waterton - Initial contribution + */ +@NonNullByDefault +public enum KnownAppId { + + $111477000094, + $111299001912, + $3201606009684, + $3201907018807, + $3201901017640, + $3201907018784, + $3201506003488, + $11091000000, + $3201910019365, + $3201909019271, + $111399000741, + $3201601007250, + $3201807016597, + $3201705012392, + $111299001563, + $3201703012065, + $3201512006963, + $111199000333, + $3201506003486, + $3201608010191, + $111399002220, + $3201710015037, + $3201601007492, + $3201412000690, + $3201908019041, + $3201602007865, + $141299000100, + $3201504001965, + $111299002012, + $3201503001543, + $3201611010983, + $3201910019378, + $3202001019933, + $3201909019241, + $121299000101, + $3201606009761, + $11101302013, + $3201906018525, + $3201711015117, + $3201810017104, + $3201602007756, + $3201809016888, + $3201912019909, + $3201503001544, + $3201711015226, + $3201505002589, + $3201710014896, + $3201706014180, + $111477000821, + $3201803015852, + $3201811017268, + $3201812017547, + $3201710014874, + $111199000385, + $3201904018282, + $3201601007494, + $3202004020643, + $3201910019457, + $11111358501, + $3202001020081, + $3201801015627, + $3201905018484, + $3201803015859, + $11101265008, + $3201709014773, + $3201610010879, + $3201707014498, + $3201708014527, + $141477000022, + $3201801015626, + $3201807016587, + $3201711015231, + $3201807016674, + $3201511006542, + $3201508004704, + $3201801015538, + $3201704012154, + $3201703011982, + $3201706012493, + $111399002178, + $3201603008210, + $3201806016381, + $3201509005146, + $3201811017353, + $3201710014863, + $3201607009975, + $3201512006945, + $3202006021142, + $3201705012304, + $3201805016367, + $3201808016760, + $3202003020459, + $3201803015991, + $3201903017912, + $3202011022252, + $3201411000389, + $3201807016618, + $3201906018623, + $111299000513, + $3201904018194, + $3201807016684, + $3201902017876, + $3201604008870, + $3202002020129, + $111399001123, + $111399000688, + $3201511006183, + $3201711015236, + $3201809016920, + $3202102022877, + $3201703012085, + $3201711015135, + $3201902017816, + $3202001019931, + $3201703012072, + $3201705012342, + $3201911019690, + $3201506003515, + $3201707014361, + $3201710014949, + $3201801015605, + $3201809016944, + $3201904018177, + $3202004020488, + $3202012022500, + $111477001084, + $3202005020803, + $3202009021746, + $3201509005241, + $3201901017667, + $3201902017805, + $3201906018589, + $3201910019513, + $3202002020207, + $3202011022316, + $3201806016390, + $3201511006303, + $3201503001595, + $3201808016753, + $3201802015704, + $3201510005851, + $111399000614, + $111477000321, + $3201611011182, + $3201710015010, + $3201711015270, + $3201906018592, + $111399001818, + $111477001142, + $3201502001401, + $3201702011856, + $3202006021030, + $3201601007242, + $3201710014880, + $3201805016238, + $3201807016667, + $3201810017123, + $3201812017448, + $3201901017681, + $3201903017932, + $3201903018099, + $3201904018148, + $3201906018571, + $3202011022310, + $3201709014853, + $3201710014943, + $3201804016166, + $3201806016457, + $3201811017306, + $3201903018024, + $3201906018671, + $3201907018724, + $3201908019017, + $3201909019144, + $3202007021295, + $3202104023386, + $3201603008165, + $3201802015822, + $3201804016080, + $3201907018838, + $3201908019025, + $3201908019062, + $3201910019499, + $3201911019575, + $3202001020086, + $3202002020178, + $3202012022492, + $3202102022872, + $3202103023104, + $3202106024080, + $3201604009179, + $11101300901, + $111399002250, + $3202002020229, + $3201505002443, + $3201802015746, + $3201508004622, + $3201806016406, + $3201905018447, + $3201603008706, + $3201806016479, + $3201905018474, + $3202007021398, + $111199000508, + $3201504002232, + $3201507004202, + $3201803015935, + $3201812017585, + $3201907018731, + $3202009021808, + $3202101022764, + $3201703012087, + $3201712015352, + $3201802015699, + $3201803016004, + $3201805016320, + $3201806016427, + $3201807016539, + $3201808016755, + $3201809016984, + $3201811017219, + $3201811017276, + $3201812017467, + $3201904018227, + $3201904018291, + $3201905018501, + $3201906018593, + $3201907018732, + $3202005020759, + $3202010022023, + $111477000722, + $3201506003105, + $3201506003414, + $3201509005084, + $3201704012267, + $3201705012355, + $3201707014446, + $3201708014611, + $3201708014652, + $3201709014747, + $3201712015402, + $3201801015628, + $3201809016892, + $3201809016985, + $3201811017183, + $3201811017190, + $3201812017384, + $3201812017444, + $3201812017553, + $3201903018100, + $3201904018119, + $3201906018622, + $3201908018930, + $3201909019268, + $3201911019579, + $3202003020389, + $3202006020897, + $3202009021792, + $3202102022907, + $111477000567, + $3201509005087, + $3201512006941, + $3201512007023, + $3201605009390, + $3201606009782, + $3201606009783, + $3201606009887, + $3201607010167, + $3201610010753, + $3201704012271, + $3201705012435, + $3201706012513, + $3201706014294, + $3201708014531, + $3201708014677, + $3201801015505, + $3201801015599, + $3201802015810, + $3201804016078, + $3201811017191, + $3201812017437, + $3201812017447, + $3201902017790, + $3201902017811, + $3201903018023, + $3201904018165, + $3201905018373, + $3201905018405, + $3201906018530, + $3201906018558, + $3201906018560, + $3201906018596, + $3201906018620, + $3201908018992, + $3201908019034, + $3201909019229, + $3201911019572, + $3201911019711, + $3201912019798, + $3201912019850, + $3202001019936, + $3202002020105, + $3202002020248, + $3202003020417, + $3202004020552, + $3202004020578, + $3202005020752, + $3202005020804, + $3202006021035, + $3202007021160, + $3202007021420, + $3202008021578, + $3202009021791, + $3202011022262, + $3202011022315, + $3202012022373, + $3202012022431, + $3202012022473, + $3202012022481, + $3202012022558, + $3202012022577, + $3202101022640, + $3202101022656, + $3202101022721, + $3202101022755, + $3202101022788, + $3202102022932, + $3202102023007, + $3202102023056, + $3202103023211, + $3202103023338, + $3202104023388, + $3202104023522, + $3202105023716, + $3202105023733, + $3202106024097, + $3202107024412, + $3202004020674, + $3202004020626; + + private final String value; + + KnownAppId() { + value = ""; + } + + KnownAppId(String value) { + this.value = value.replace("$", ""); + } + + KnownAppId(KnownAppId otherAppId) { + this(otherAppId.getValue()); + } + + public String getValue() { + if ("".equals(value)) { + return this.name().replace("$", ""); + } + return value.replace("$", ""); + } + + public static Stream stream() { + return Stream.of(KnownAppId.values()).map(a -> a.getValue()); + } +} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteController.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteController.java old mode 100644 new mode 100755 index 54b956af6ddf5..420d780c2ddbe --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteController.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteController.java @@ -12,8 +12,6 @@ */ package org.openhab.binding.samsungtv.internal.protocol; -import java.util.List; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -21,6 +19,7 @@ * The {@link RemoteController} is the base class for handling remote control keys for the Samsung TV. * * @author Arjan Mels - Initial contribution + * @author Nick Waterton - added getArtmodeStatus(), sendKeyPress() */ @NonNullByDefault public abstract class RemoteController implements AutoCloseable { @@ -40,9 +39,23 @@ public RemoteController(String host, int port, @Nullable String appName, @Nullab public abstract boolean isConnected(); - public abstract void sendKey(KeyCode key) throws RemoteControllerException; + public abstract void sendUrl(String command); + + public abstract void sendSourceApp(String command); + + public abstract boolean closeApp(); + + public abstract void getAppStatus(String id); + + public abstract void updateCurrentApp(); + + public abstract boolean noApps(); + + public abstract void sendKeyPress(KeyCode key, int duration); + + public abstract void sendKey(Object key); - public abstract void sendKeys(List keys) throws RemoteControllerException; + public abstract void getArtmodeStatus(String... optionalRequests); @Override public abstract void close() throws RemoteControllerException; diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerException.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerException.java old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerLegacy.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerLegacy.java old mode 100644 new mode 100755 index 1af64dd31c6d3..d014d0ec91f28 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerLegacy.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerLegacy.java @@ -23,11 +23,10 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.util.Arrays; -import java.util.Base64; -import java.util.List; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.samsungtv.internal.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,6 +41,7 @@ * * @author Pauli Anttila - Initial contribution * @author Arjan Mels - Renamed and reworked to use RemoteController base class, to allow different protocols + * @author Nick Waterton - moved Sendkeys to RemoteController, reworked sendkey, sendKeyData */ @NonNullByDefault public class RemoteControllerLegacy extends RemoteController { @@ -85,16 +85,22 @@ public RemoteControllerLegacy(String host, int port, @Nullable String appName, @ * * @throws RemoteControllerException */ - @Override public void openConnection() throws RemoteControllerException { - logger.debug("Open connection to host '{}:{}'", host, port); + if (isConnected()) { + return; + } + logger.debug("{}: Open connection to host '{}:{}'", host, host, port); Socket localsocket = new Socket(); socket = localsocket; try { - socket.connect(new InetSocketAddress(host, port), CONNECTION_TIMEOUT); + if (socket != null) { + socket.connect(new InetSocketAddress(host, port), CONNECTION_TIMEOUT); + } else { + throw new IOException("no Socket"); + } } catch (IOException e) { - logger.debug("Cannot connect to Legacy Remote Controller: {}", e.getMessage()); + logger.debug("{}: Cannot connect to Legacy Remote Controller: {}", host, e.getMessage()); throw new RemoteControllerException("Connection failed", e); } @@ -106,7 +112,7 @@ public void openConnection() throws RemoteControllerException { InputStreamReader localreader = new InputStreamReader(inputStream); reader = localreader; - logger.debug("Connection successfully opened...querying access"); + logger.debug("{}: Connection successfully opened...querying access", host); writeInitialInfo(localwriter, localsocket); readInitialInfo(localreader); @@ -172,7 +178,7 @@ private void readInitialInfo(Reader reader) throws RemoteControllerException { char[] result = readCharArray(reader); if (Arrays.equals(result, ACCESS_GRANTED_RESP)) { - logger.debug("Access granted"); + logger.debug("{}: Access granted", host); } else if (Arrays.equals(result, ACCESS_DENIED_RESP)) { throw new RemoteControllerException("Access denied"); } else if (Arrays.equals(result, ACCESS_TIMEOUT_RESP)) { @@ -190,100 +196,81 @@ private void readInitialInfo(Reader reader) throws RemoteControllerException { /** * Close connection to Samsung TV. * - * @throws RemoteControllerException */ - public void closeConnection() throws RemoteControllerException { + public void closeConnection() { try { if (socket != null) { socket.close(); } } catch (IOException e) { - throw new RemoteControllerException(e); + // ignore error } } - /** - * Send key code to Samsung TV. - * - * @param key Key code to send. - * @throws RemoteControllerException - */ - @Override - public void sendKey(KeyCode key) throws RemoteControllerException { - logger.debug("Try to send command: {}", key); + public void sendUrl(String command) { + logger.warn("{}: Remote control legacy: unsupported command: {}", host, command); + } - if (!isConnected()) { - openConnection(); - } + public void sendSourceApp(String command) { + logger.warn("{}: Remote control legacy: unsupported command: {}", host, command); + } - try { - sendKeyData(key); - } catch (RemoteControllerException e) { - logger.debug("Couldn't send command", e); - logger.debug("Retry one time..."); + public void updateCurrentApp() { + } - closeConnection(); - openConnection(); + public void getArtmodeStatus(String... optionalRequests) { + } - sendKeyData(key); - } + public boolean closeApp() { + return false; + } - logger.debug("Command successfully sent"); + public void getAppStatus(String id) { } - /** - * Send sequence of key codes to Samsung TV. - * - * @param keys List of key codes to send. - * @throws RemoteControllerException - */ - @Override - public void sendKeys(List keys) throws RemoteControllerException { - sendKeys(keys, 300); + public boolean noApps() { + return false; + } + + private void logResult(String msg, Throwable cause) { + if (logger.isTraceEnabled()) { + logger.trace("{}: {}: ", host, msg, cause); + } else { + logger.debug("{}: {}: {}", host, msg, cause.getMessage()); + } } /** - * Send sequence of key codes to Samsung TV. + * Send key code to Samsung TV. * - * @param keys List of key codes to send. - * @param sleepInMs Sleep between key code sending in milliseconds. - * @throws RemoteControllerException + * @param key Key code to send. */ - public void sendKeys(List keys, int sleepInMs) throws RemoteControllerException { - logger.debug("Try to send sequence of commands: {}", keys); - - if (!isConnected()) { - openConnection(); + public void sendKey(Object key) { + if (!(key instanceof KeyCode)) { + logger.warn("{}: Remote control legacy: unsupported command: {}", host, key); + return; } - - for (int i = 0; i < keys.size(); i++) { - KeyCode key = keys.get(i); + logger.trace("{}: Try to send command: {}", host, key); + for (int i = 0; i < 2; i++) { try { - sendKeyData(key); - } catch (RemoteControllerException e) { - logger.debug("Couldn't send command", e); - logger.debug("Retry one time..."); - - closeConnection(); openConnection(); - - sendKeyData(key); - } - - if ((keys.size() - 1) != i) { - // Sleep a while between commands - try { - Thread.sleep(sleepInMs); - } catch (InterruptedException e) { + if (sendKeyData((KeyCode) key)) { + logger.trace("{}: Command successfully sent", host); return; } + } catch (RemoteControllerException e) { + logResult("Couldn't send command", e); } + closeConnection(); + logger.debug("{}: Retry send command {} attempt {}...", host, key, i); } + logger.warn("{}: Command Retrys failed", host); + } - logger.debug("Command(s) successfully sent"); + public void sendKeyPress(KeyCode key, int duration) { + sendKey(key); } - @Override public boolean isConnected() { return socket != null && !socket.isClosed() && socket.isConnected(); } @@ -321,13 +308,11 @@ private void writeString(Writer writer, String str) throws IOException { } private void writeBase64String(Writer writer, String str) throws IOException { - String tmp = Base64.getEncoder().encodeToString(str.getBytes()); - writeString(writer, tmp); + writeString(writer, Utils.b64encode(str)); } private String readString(Reader reader) throws IOException { - char[] buf = readCharArray(reader); - return new String(buf); + return new String(readCharArray(reader)); } private char[] readCharArray(Reader reader) throws IOException { @@ -344,13 +329,13 @@ private char[] readCharArray(Reader reader) throws IOException { } } - private void sendKeyData(KeyCode key) throws RemoteControllerException { - logger.debug("Sending key code {}", key.getValue()); + private boolean sendKeyData(KeyCode key) { + logger.debug("{}: Sending key code {}", host, key.getValue()); Writer localwriter = writer; Reader localreader = reader; if (localwriter == null || localreader == null) { - return; + return false; } /* @formatter:off * @@ -378,8 +363,10 @@ private void sendKeyData(KeyCode key) throws RemoteControllerException { readString(localreader); readCharArray(localreader); } catch (IOException e) { - throw new RemoteControllerException(e); + logResult("Couldn't send command", e); + return false; } + return true; } private String createKeyDataPayload(KeyCode key) throws IOException { diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebSocket.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebSocket.java old mode 100644 new mode 100755 index 28ea876260c70..2c03e111defbb --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebSocket.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebSocket.java @@ -12,27 +12,36 @@ */ package org.openhab.binding.samsungtv.internal.protocol; +import static org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration.*; + import java.net.URI; import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.Base64; -import java.util.LinkedHashMap; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.component.LifeCycle; import org.eclipse.jetty.util.component.LifeCycle.Listener; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.websocket.client.WebSocketClient; -import org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration; +import org.openhab.binding.samsungtv.internal.SamsungTvAppWatchService; +import org.openhab.binding.samsungtv.internal.Utils; +import org.openhab.binding.samsungtv.internal.service.RemoteControllerService; import org.openhab.core.io.net.http.WebSocketFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; /** * The {@link RemoteControllerWebSocket} is responsible for sending key codes to the @@ -40,6 +49,7 @@ * * @author Arjan Mels - Initial contribution * @author Arjan Mels - Moved websocket inner classes to standalone classes + * @author Nick Waterton - added Action enum, manual app handling and some refactoring */ @NonNullByDefault public class RemoteControllerWebSocket extends RemoteController implements Listener { @@ -55,37 +65,33 @@ public class RemoteControllerWebSocket extends RemoteController implements Liste private final WebSocketArt webSocketArt; private final WebSocketV2 webSocketV2; + // refresh limit for current app update (in seconds) + private static final long UPDATE_CURRENT_APP_REFRESH_SECONDS = 10; + private Instant previousUpdateCurrentApp = Instant.MIN; + // JSON parser class. Also used by WebSocket handlers. - final Gson gson = new Gson(); + public final Gson gson = new Gson(); // Callback class. Also used by WebSocket handlers. - final RemoteControllerWebsocketCallback callback; + final RemoteControllerService callback; // Websocket client class shared by WebSocket handlers. final WebSocketClient client; - // temporary storage for source app. Will be used as value for the sourceApp channel when information is complete. - // Also used by Websocket handlers. - @Nullable - String currentSourceApp = null; + // App File servicce + private final SamsungTvAppWatchService samsungTvAppWatchService; - // last app in the apps list: used to detect when status information is complete in WebSocketV2. - @Nullable - String lastApp = null; - - // timeout for status information search - private static final long UPDATE_CURRENT_APP_TIMEOUT = 5000; - private long previousUpdateCurrentApp = 0; + // list instaled apps after 2 updates + public int updateCount = 0; // UUID used for data exchange via websockets final UUID uuid = UUID.randomUUID(); // Description of Apps - @NonNullByDefault() - class App { - String appId; - String name; - int type; + public class App { + public String appId; + public String name; + public int type; App(String appId, String name, int type) { this.appId = appId; @@ -97,10 +103,58 @@ class App { public String toString() { return this.name; } + + public String getAppId() { + return Optional.ofNullable(appId).orElse(""); + } + + public String getName() { + return Optional.ofNullable(name).orElse(""); + } + + public void setName(String name) { + this.name = name; + } + + public int getType() { + return Optional.ofNullable(type).orElse(2); + } } // Map of all available apps - Map apps = new LinkedHashMap<>(); + public Map apps = new ConcurrentHashMap<>(); + // manually added apps (from File) + public Map manApps = new ConcurrentHashMap<>(); + + /** + * The {@link Action} presents available actions for keys with Samsung TV. + * + */ + public static enum Action { + + CLICK("Click"), + PRESS("Press"), + RELEASE("Release"), + MOVE("Move"), + END("End"), + TEXT("Text"), + MOUSECLICK("MouseClick"); + + private final String value; + + Action() { + value = "Click"; + } + + Action(String newvalue) { + this.value = newvalue; + } + + @Override + public String toString() { + return value; + } + } /** * Create and initialize remote controller instance. @@ -109,22 +163,25 @@ public String toString() { * @param port TCP port of the remote controller protocol. * @param appName Application name used to send key codes. * @param uniqueId Unique Id used to send key codes. - * @param remoteControllerWebsocketCallback callback + * @param callback RemoteControllerService callback * @throws RemoteControllerException */ public RemoteControllerWebSocket(String host, int port, String appName, String uniqueId, - RemoteControllerWebsocketCallback remoteControllerWebsocketCallback) throws RemoteControllerException { + RemoteControllerService callback) throws RemoteControllerException { super(host, port, appName, uniqueId); + this.callback = callback; - this.callback = remoteControllerWebsocketCallback; - - WebSocketFactory webSocketFactory = remoteControllerWebsocketCallback.getWebSocketFactory(); + WebSocketFactory webSocketFactory = callback.getWebSocketFactory(); if (webSocketFactory == null) { throw new RemoteControllerException("No WebSocketFactory available"); } - client = webSocketFactory.createWebSocketClient("samsungtv"); + this.samsungTvAppWatchService = new SamsungTvAppWatchService(host, this); + SslContextFactory sslContextFactory = new SslContextFactory.Client( /* trustall= */ true); + /* remove extra filters added by jetty on cipher suites */ + sslContextFactory.setExcludeCipherSuites(); + client = webSocketFactory.createWebSocketClient("samsungtv", sslContextFactory); client.addLifeCycleListener(this); webSocketRemote = new WebSocketRemote(this); @@ -132,67 +189,73 @@ public RemoteControllerWebSocket(String host, int port, String appName, String u webSocketV2 = new WebSocketV2(this); } - @Override public boolean isConnected() { + if (callback.getArtModeSupported()) { + return webSocketRemote.isConnected() && webSocketArt.isConnected(); + } return webSocketRemote.isConnected(); } - @Override public void openConnection() throws RemoteControllerException { - logger.trace("openConnection()"); + logger.trace("{}: openConnection()", host); if (!(client.isStarted() || client.isStarting())) { - logger.debug("RemoteControllerWebSocket start Client"); + logger.debug("{}: RemoteControllerWebSocket start Client", host); try { client.start(); - client.setMaxBinaryMessageBufferSize(1000000); + client.setMaxBinaryMessageBufferSize(1024 * 1024); // websocket connect will be done in lifetime handler return; } catch (Exception e) { - logger.warn("Cannot connect to websocket remote control interface: {}", e.getMessage(), e); + logger.warn("{}: Cannot connect to websocket remote control interface: {}", host, e.getMessage()); throw new RemoteControllerException(e); } } connectWebSockets(); } - private void connectWebSockets() { - logger.trace("connectWebSockets()"); - - String encodedAppName = Base64.getUrlEncoder().encodeToString(appName.getBytes()); - - String protocol; - - if (SamsungTvConfiguration.PROTOCOL_SECUREWEBSOCKET - .equals(callback.getConfig(SamsungTvConfiguration.PROTOCOL))) { - protocol = "wss"; + private void logResult(String msg, Throwable cause) { + if (logger.isTraceEnabled()) { + logger.trace("{}: {}: ", host, msg, cause); } else { - protocol = "ws"; + logger.warn("{}: {}: {}", host, msg, cause.getMessage()); } + } + + private void connectWebSockets() { + logger.trace("{}: connectWebSockets()", host); + String encodedAppName = Utils.b64encode(appName); + + String protocol = PROTOCOL_SECUREWEBSOCKET.equals(callback.handler.configuration.getProtocol()) ? "wss" : "ws"; try { - String token = (String) callback.getConfig(SamsungTvConfiguration.WEBSOCKET_TOKEN); + String token = callback.handler.configuration.getWebsocketToken(); + if ("wss".equals(protocol) && token.isBlank()) { + logger.warn( + "{}: WebSocketRemote connecting without Token, please accept the connection on the TV within 30 seconds", + host); + } webSocketRemote.connect(new URI(protocol, null, host, port, WS_ENDPOINT_REMOTE_CONTROL, - "name=" + encodedAppName + (StringUtil.isNotBlank(token) ? "&token=" + token : ""), null)); + "name=" + encodedAppName + (token.isBlank() ? "" : "&token=" + token), null)); } catch (RemoteControllerException | URISyntaxException e) { - logger.warn("Problem connecting to remote websocket", e); + logResult("Problem connecting to remote websocket", e); } try { webSocketArt.connect(new URI(protocol, null, host, port, WS_ENDPOINT_ART, "name=" + encodedAppName, null)); } catch (RemoteControllerException | URISyntaxException e) { - logger.warn("Problem connecting to artmode websocket", e); + logResult("Problem connecting to artmode websocket", e); } try { webSocketV2.connect(new URI(protocol, null, host, port, WS_ENDPOINT_V2, "name=" + encodedAppName, null)); } catch (RemoteControllerException | URISyntaxException e) { - logger.warn("Problem connecting to V2 websocket", e); + logResult("Problem connecting to V2 websocket", e); } } private void closeConnection() throws RemoteControllerException { - logger.debug("RemoteControllerWebSocket closeConnection"); + logger.debug("{}: RemoteControllerWebSocket closeConnection", host); try { webSocketRemote.close(); @@ -206,177 +269,227 @@ private void closeConnection() throws RemoteControllerException { @Override public void close() throws RemoteControllerException { - logger.debug("RemoteControllerWebSocket close"); + logger.debug("{}: RemoteControllerWebSocket close", host); closeConnection(); } + public boolean noApps() { + return apps.isEmpty(); + } + + public void listApps() { + Stream> st = (noApps()) ? manApps.entrySet().stream() : apps.entrySet().stream(); + logger.debug("{}: Installed Apps: {}", host, + st.map(entry -> entry.getValue().appId + " = " + entry.getKey()).collect(Collectors.joining(", "))); + } + /** * Retrieve app status for all apps. In the WebSocketv2 handler the currently running app will be determined */ - void updateCurrentApp() { - if (webSocketV2.isNotConnected()) { - logger.warn("Cannot retrieve current app webSocketV2 is not connected"); + public synchronized void updateCurrentApp() { + // limit noApp refresh rate + if (noApps() + && Instant.now().isBefore(previousUpdateCurrentApp.plusSeconds(UPDATE_CURRENT_APP_REFRESH_SECONDS))) { return; } - - // update still running and not timed out - if (lastApp != null && System.currentTimeMillis() < previousUpdateCurrentApp + UPDATE_CURRENT_APP_TIMEOUT) { + previousUpdateCurrentApp = Instant.now(); + if (webSocketV2.isNotConnected()) { + logger.warn("{}: Cannot retrieve current app webSocketV2 is not connected", host); return; } - - lastApp = null; - previousUpdateCurrentApp = System.currentTimeMillis(); - - currentSourceApp = null; - - // retrieve last app (don't merge with next loop as this might run asynchronously - for (App app : apps.values()) { - lastApp = app.appId; + // if noapps by this point, start file app service + if (updateCount >= 1 && noApps() && !samsungTvAppWatchService.getStarted()) { + samsungTvAppWatchService.start(); } - - for (App app : apps.values()) { - webSocketV2.getAppStatus(app.appId); + // list apps + if (updateCount++ == 2) { + listApps(); + } + for (App app : (noApps()) ? manApps.values() : apps.values()) { + webSocketV2.getAppStatus(app.getAppId()); + // prevent being called again if this takes a while + previousUpdateCurrentApp = Instant.now(); } } /** - * Send key code to Samsung TV. - * - * @param key Key code to send. - * @throws RemoteControllerException + * Update manual App list from file (called from SamsungTvAppWatchService) */ - @Override - public void sendKey(KeyCode key) throws RemoteControllerException { - sendKey(key, false); - } - - public void sendKeyPress(KeyCode key) throws RemoteControllerException { - sendKey(key, true); - } - - public void sendKey(KeyCode key, boolean press) throws RemoteControllerException { - logger.debug("Try to send command: {}", key); - - if (!isConnected()) { - openConnection(); - } - - try { - sendKeyData(key, press); - } catch (RemoteControllerException e) { - logger.debug("Couldn't send command", e); - logger.debug("Retry one time..."); - - closeConnection(); - openConnection(); - - sendKeyData(key, press); - } + public void updateAppList(List fileApps) { + previousUpdateCurrentApp = Instant.now(); + manApps.clear(); + fileApps.forEach(line -> { + try { + App app = gson.fromJson(line, App.class); + if (app != null) { + manApps.put(app.getName(), new App(app.getAppId(), app.getName(), app.getType())); + logger.debug("{}: Added app: {}/{}", host, app.getName(), app.getAppId()); + } + } catch (JsonSyntaxException e) { + logger.warn("{}: cannot add app, wrong format {}: {}", host, line, e.getMessage()); + } + }); + addKnownAppIds(); + updateCount = 0; } /** - * Send sequence of key codes to Samsung TV. - * - * @param keys List of key codes to send. - * @throws RemoteControllerException + * Add all know app id's to manApps */ - @Override - public void sendKeys(List keys) throws RemoteControllerException { - sendKeys(keys, 300); + public void addKnownAppIds() { + KnownAppId.stream().filter(id -> !manApps.values().stream().anyMatch(a -> a.getAppId().equals(id))) + .forEach(id -> { + previousUpdateCurrentApp = Instant.now(); + manApps.put(id, new App(id, id, 2)); + logger.debug("{}: Added Known appId: {}", host, id); + }); } /** - * Send sequence of key codes to Samsung TV. + * Send key code to Samsung TV. * - * @param keys List of key codes to send. - * @param sleepInMs Sleep between key code sending in milliseconds. - * @throws RemoteControllerException + * @param key Key code to send. */ - public void sendKeys(List keys, int sleepInMs) throws RemoteControllerException { - logger.debug("Try to send sequence of commands: {}", keys); - - if (!isConnected()) { - openConnection(); + public void sendKey(Object key) { + if (key instanceof KeyCode keyAsKeyCode) { + sendKey(keyAsKeyCode, Action.CLICK); + } else if (key instanceof String) { + sendKey((String) key); } + } - for (int i = 0; i < keys.size(); i++) { - KeyCode key = keys.get(i); - try { - sendKeyData(key, false); - } catch (RemoteControllerException e) { - logger.debug("Couldn't send command", e); - logger.debug("Retry one time..."); - - closeConnection(); - openConnection(); - - sendKeyData(key, false); + public void sendKey(String value) { + try { + if (value.startsWith("{")) { + sendKeyData(value, Action.MOVE); + } else if ("LeftClick".equals(value) || "RightClick".equals(value)) { + sendKeyData(value, Action.MOUSECLICK); + } else if (value.isEmpty()) { + sendKeyData("", Action.END); + } else { + sendKeyData(value, Action.TEXT); } + } catch (RemoteControllerException e) { + logger.debug("{}: Couldn't send Text/Mouse move {}", host, e.getMessage()); + } + } + + public void sendKey(KeyCode key, Action action) { + try { + sendKeyData(key, action); + } catch (RemoteControllerException e) { + logger.debug("{}: Couldn't send command {}", host, e.getMessage()); + } + } - if ((keys.size() - 1) != i) { - // Sleep a while between commands - try { - Thread.sleep(sleepInMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + public void sendKeyPress(KeyCode key, int duration) { + sendKey(key, Action.PRESS); + // send key release in duration milliseconds + @Nullable + ScheduledExecutorService scheduler = callback.getScheduler(); + if (scheduler != null) { + scheduler.schedule(() -> { + if (isConnected()) { + sendKey(key, Action.RELEASE); } - } + }, duration, TimeUnit.MILLISECONDS); } + } - logger.debug("Command(s) successfully sent"); + private void sendKeyData(Object key, Action action) throws RemoteControllerException { + logger.debug("{}: Try to send Key: {}, Action: {}", host, key, action); + webSocketRemote.sendKeyData(action.toString(), key.toString()); } - private void sendKeyData(KeyCode key, boolean press) throws RemoteControllerException { - webSocketRemote.sendKeyData(press, key.toString()); + public void sendSourceApp(String appName) { + if (appName.toLowerCase().contains("slideshow")) { + webSocketArt.setSlideshow(appName); + } else { + sendSourceApp(appName, null); + } } - public void sendSourceApp(String app) { - String appName = app; - App appVal = apps.get(app); - boolean deepLink = false; - appName = appVal.appId; - deepLink = appVal.type == 2; + public void sendSourceApp(final String appName, @Nullable String url) { + Stream> st = (noApps()) ? manApps.entrySet().stream() : apps.entrySet().stream(); + boolean found = st.filter(a -> a.getKey().equals(appName) || a.getValue().name.equals(appName)) + .map(a -> sendSourceApp(a.getValue().appId, a.getValue().type == 2, url)).findFirst().orElse(false); + if (!found) { + // treat appName as appId with optional type number eg "3201907018807, 2" + String[] appArray = (url == null) ? appName.trim().split(",") : "org.tizen.browser,4".split(","); + sendSourceApp(appArray[0].trim(), (appArray.length > 1) ? "2".equals(appArray[1].trim()) : true, url); + } + } - webSocketRemote.sendSourceApp(appName, deepLink); + public boolean sendSourceApp(String appId, boolean type, @Nullable String url) { + if (noApps()) { + // 2020 TV's and later use webSocketV2 for app launch + webSocketV2.sendSourceApp(appId, type, url); + } else { + if (webSocketV2.isConnected() && url == null) { + // it seems all Tizen TV's can use webSocketV2 if it connects + webSocketV2.sendSourceApp(appId, type, url); + } else { + webSocketRemote.sendSourceApp(appId, type, url); + } + } + return true; } public void sendUrl(String url) { String processedUrl = url.replace("/", "\\/"); - webSocketRemote.sendSourceApp("org.tizen.browser", false, processedUrl); + sendSourceApp("Internet", processedUrl); } - public List getAppList() { - ArrayList appList = new ArrayList<>(); - for (App app : apps.values()) { - appList.add(app.name); + public boolean closeApp() { + return webSocketV2.closeApp(); + } + + /** + * Get app status after 3 second delay (apps take 3s to launch) + */ + public void getAppStatus(String id) { + @Nullable + ScheduledExecutorService scheduler = callback.getScheduler(); + if (scheduler != null) { + scheduler.schedule(() -> { + if (webSocketV2.isConnected()) { + if (!id.isBlank()) { + webSocketV2.getAppStatus(id); + } else { + updateCurrentApp(); + } + } + }, 3000, TimeUnit.MILLISECONDS); } - return appList; + } + + public void getArtmodeStatus(String... optionalRequests) { + webSocketArt.getArtmodeStatus(optionalRequests); } @Override public void lifeCycleStarted(@Nullable LifeCycle arg0) { - logger.trace("WebSocketClient started"); + logger.trace("{}: WebSocketClient started", host); connectWebSockets(); } @Override public void lifeCycleFailure(@Nullable LifeCycle arg0, @Nullable Throwable throwable) { - logger.warn("WebSocketClient failure: {}", throwable != null ? throwable.toString() : null); + logger.warn("{}: WebSocketClient failure: {}", host, throwable != null ? throwable.toString() : null); } @Override public void lifeCycleStarting(@Nullable LifeCycle arg0) { - logger.trace("WebSocketClient starting"); + logger.trace("{}: WebSocketClient starting", host); } @Override public void lifeCycleStopped(@Nullable LifeCycle arg0) { - logger.trace("WebSocketClient stopped"); + logger.trace("{}: WebSocketClient stopped", host); } @Override public void lifeCycleStopping(@Nullable LifeCycle arg0) { - logger.trace("WebSocketClient stopping"); + logger.trace("{}: WebSocketClient stopping", host); } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebsocketCallback.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebsocketCallback.java deleted file mode 100644 index 36af8af9a5e13..0000000000000 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/RemoteControllerWebsocketCallback.java +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.samsungtv.internal.protocol; - -import java.util.List; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.io.net.http.WebSocketFactory; - -/** - * Callback from the websocket remote controller - * - * @author Arjan Mels - Initial contribution - */ -@NonNullByDefault -public interface RemoteControllerWebsocketCallback { - - void appsUpdated(List apps); - - void currentAppUpdated(@Nullable String app); - - void powerUpdated(boolean on, boolean artmode); - - void connectionError(@Nullable Throwable error); - - void putConfig(String token, Object object); - - @Nullable - Object getConfig(String token); - - @Nullable - WebSocketFactory getWebSocketFactory(); -} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketArt.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketArt.java old mode 100644 new mode 100755 index 9dc21f7c259bf..2477ae621287a --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketArt.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketArt.java @@ -12,8 +12,44 @@ */ package org.openhab.binding.samsungtv.internal.protocol; +import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; +import java.net.URLConnection; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLContext; +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.core.library.types.DecimalType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.RawType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,36 +60,274 @@ * Websocket class to retrieve artmode status (on o.a. the Frame TV's) * * @author Arjan Mels - Initial contribution + * @author Nick Waterton - added slideshow handling, upload/download, refactoring */ @NonNullByDefault class WebSocketArt extends WebSocketBase { private final Logger logger = LoggerFactory.getLogger(WebSocketArt.class); + private String host = ""; + private String className = ""; + private String slideShowDuration = "off"; + // Favourites is default + private String categoryId = "MY-C0004"; + private String lastThumbnail = ""; + private boolean slideshow = false; + public byte[] imageBytes = new byte[0]; + public String fileType = "jpg"; + private long connection_id_random = 2705890518L; + private static final DateTimeFormatter DATEFORMAT = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss") + .withZone(ZoneId.systemDefault()); + private Map stateMap = Collections.synchronizedMap(new HashMap<>()); + private @Nullable SSLSocketFactory sslsocketfactory = null; + /** * @param remoteControllerWebSocket */ WebSocketArt(RemoteControllerWebSocket remoteControllerWebSocket) { super(remoteControllerWebSocket); + this.host = remoteControllerWebSocket.host; + this.className = this.getClass().getSimpleName(); + + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, acceptAlltrustManagers(), null); + sslsocketfactory = sslContext.getSocketFactory(); + } catch (KeyManagementException | NoSuchAlgorithmException e) { + logger.debug("{}: sslsocketfactory failed to initialize: {}", host, e.getMessage()); + } } @NonNullByDefault({}) - private static class JSONMessage { + @SuppressWarnings("unused") + private class JSONMessage { String event; - @NonNullByDefault({}) - static class Data { + class Data { String event; String status; + String version; String value; + String current_content_id; + String content_id; + String category_id; + String is_shown; + String type; + String file_type; + String conn_info; + String data; + + public String getEvent() { + return Optional.ofNullable(event).orElse(""); + } + + public String getStatus() { + return Optional.ofNullable(status).orElse(""); + } + + public int getVersion() { + return Optional.ofNullable(version).map(a -> a.replace(".", "")).map(Integer::parseInt).orElse(0); + } + + public String getValue() { + return Optional.ofNullable(value).orElse(getStatus()); + } + + public int getIntValue() { + return Optional.of(Integer.valueOf(getValue())).orElse(0); + } + + public String getCategoryId() { + return Optional.ofNullable(category_id).orElse(""); + } + + public String getContentId() { + return Optional.ofNullable(content_id).orElse(getCurrentContentId()); + } + + public String getCurrentContentId() { + return Optional.ofNullable(current_content_id).orElse(""); + } + + public String getType() { + return Optional.ofNullable(type).orElse(""); + } + + public String getIsShown() { + return Optional.ofNullable(is_shown).orElse("No"); + } + + public String getFileType() { + return Optional.ofNullable(file_type).orElse(""); + } + + public String getConnInfo() { + return Optional.ofNullable(conn_info).orElse(""); + } + + public String getData() { + return Optional.ofNullable(data).orElse(""); + } + } + + class BinaryData { + byte[] data; + int off; + int len; + + BinaryData(byte[] data, int off, int len) { + this.data = data; + this.off = off; + this.len = len; + } + + public byte[] getBinaryData() { + return Optional.ofNullable(data).orElse(new byte[0]); + } + + public int getOff() { + return Optional.ofNullable(off).orElse(0); + } + + public int getLen() { + return Optional.ofNullable(len).orElse(0); + } + } + + class ArtmodeSettings { + String item; + String value; + String min; + String max; + String valid_values; + + public String getItem() { + return Optional.ofNullable(item).orElse(""); + } + + public int getValue() { + return Optional.ofNullable(value).map(a -> Integer.valueOf(a)).orElse(0); + } + } + + class Conninfo { + String d2d_mode; + long connection_id; + String request_id; + String id; + } + + class Contentinfo { + String contentInfo; + String event; + String ip; + String port; + String key; + String stat; + boolean secured; + String mode; + + public String getContentInfo() { + return Optional.ofNullable(contentInfo).orElse(""); + } + + public String getIp() { + return Optional.ofNullable(ip).orElse(""); + } + + public int getPort() { + return Optional.ofNullable(port).map(Integer::parseInt).orElse(0); + } + + public String getKey() { + return Optional.ofNullable(key).orElse(""); + } + + public boolean getSecured() { + return Optional.ofNullable(secured).orElse(false); + } + } + + class Header { + String connection_id; + String seckey; + String version; + String fileID; + String fileName; + String fileType; + String num; + String total; + String fileLength; + + Header(int fileLength) { + this.fileLength = String.valueOf(fileLength); + } + + public int getFileLength() { + return Optional.ofNullable(fileLength).map(Integer::parseInt).orElse(0); + } + + public String getFileType() { + return Optional.ofNullable(fileType).orElse(""); + } + + public String getFileID() { + return Optional.ofNullable(fileID).orElse(""); + } } // data is sometimes a json object, sometimes a string representation of a json object for d2d_service_message @Nullable JsonElement data; + + BinaryData binData; + + public String getEvent() { + return Optional.ofNullable(event).orElse(""); + } + + public String getData() { + return Optional.ofNullable(data).map(a -> a.getAsString()).orElse(""); + } + + public void putBinaryData(byte[] arr, int off, int len) { + this.binData = new BinaryData(arr, off, len); + } + + public BinaryData getBinaryData() { + return Optional.ofNullable(binData).orElse(new BinaryData(new byte[0], 0, 0)); + } } @Override - public void onWebSocketText(@Nullable String msgarg) { + public void onWebSocketBinary(byte @Nullable [] arr, int off, int len) { + if (arr == null) { + return; + } + super.onWebSocketBinary(arr, off, len); + String msg = extractMsg(arr, off, len, true); + // offset is start of binary data + int offset = ByteBuffer.wrap(arr, off, len).getShort() + off + 2; // 2 = length of Short + try { + JSONMessage jsonMsg = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.class); + if (jsonMsg == null) { + return; + } + switch (jsonMsg.getEvent()) { + case "d2d_service_message": + jsonMsg.putBinaryData(arr, offset, len); + handleD2DServiceMessage(jsonMsg); + break; + default: + logger.debug("{}: WebSocketArt(binary) Unknown event: {}", host, msg); + } + } catch (JsonSyntaxException e) { + logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg); + } + } + + @Override + public synchronized void onWebSocketText(@Nullable String msgarg) { if (msgarg == null) { return; } @@ -61,91 +335,338 @@ public void onWebSocketText(@Nullable String msgarg) { super.onWebSocketText(msg); try { JSONMessage jsonMsg = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.class); - - switch (jsonMsg.event) { + if (jsonMsg == null) { + return; + } + switch (jsonMsg.getEvent()) { case "ms.channel.connect": - logger.debug("Art channel connected"); + logger.debug("{}: Art channel connected", host); break; case "ms.channel.ready": - logger.debug("Art channel ready"); + logger.debug("{}: Art channel ready", host); + stateMap.clear(); + if (remoteControllerWebSocket.callback.getArtMode2022()) { + remoteControllerWebSocket.callback.setArtMode2022(false); + remoteControllerWebSocket.callback.setArtModeSupported(true); + logger.info("{}: Art Mode has been renabled on Frame TV's >= 2022", host); + } getArtmodeStatus(); + getArtmodeStatus("get_api_version"); + getArtmodeStatus("api_version"); + getArtmodeStatus("get_slideshow_status"); + getArtmodeStatus("get_auto_rotation_status"); + getArtmodeStatus("get_current_artwork"); + getArtmodeStatus("get_color_temperature"); break; case "ms.channel.clientConnect": - logger.debug("Art client connected"); + logger.debug("{}: Another Art client has connected", host); break; case "ms.channel.clientDisconnect": - logger.debug("Art client disconnected"); + logger.debug("{}: Other Art client has disconnected", host); break; case "d2d_service_message": - if (jsonMsg.data != null) { - handleD2DServiceMessage(jsonMsg.data.getAsString()); - } else { - logger.debug("Empty d2d_service_message event: {}", msg); - } + handleD2DServiceMessage(jsonMsg); break; default: - logger.debug("WebSocketArt Unknown event: {}", msg); + logger.debug("{}: WebSocketArt Unknown event: {}", host, msg); } } catch (JsonSyntaxException e) { - logger.warn("{}: Error ({}) in message: {}", this.getClass().getSimpleName(), e.getMessage(), msg, e); + logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg); } } - private void handleD2DServiceMessage(String msg) { - JSONMessage.Data data = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.Data.class); - if (data.event == null) { - logger.debug("Unknown d2d_service_message event: {}", msg); - return; - } else { - switch (data.event) { - case "art_mode_changed": - logger.debug("art_mode_changed: {}", data.status); - if ("on".equals(data.status)) { - remoteControllerWebSocket.callback.powerUpdated(false, true); - } else { - remoteControllerWebSocket.callback.powerUpdated(true, false); + /** + * handle D2DServiceMessages + * + * @param jsonMsg JSONMessage + * + */ + private synchronized void handleD2DServiceMessage(JSONMessage jsonMsg) { + String msg = jsonMsg.getData(); + try { + JSONMessage.Data data = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.Data.class); + if (data == null) { + logger.debug("{}: Empty d2d_service_message event", host); + return; + } + // remove returns and white space for ART_JSON channel + valueReceived(ART_JSON, new StringType(msg.trim().replaceAll("\\n|\\\\n", "").replaceAll("\\s{2,}", " "))); + switch (data.getEvent()) { + case "error": + logger.debug("{}: ERROR event: {}", host, msg); + break; + case "api_version": + // old (2021) version is "2.03", new (2022) version is "4.3.4.0" + logger.debug("{}: {}: {}", host, data.getEvent(), data.getVersion()); + if (data.getVersion() >= 4000) { + setArtApiVersion(1); + } + logger.debug("{}: API Version set to: {}", host, getArtApiVersion()); + break; + case "send_image": + case "image_deleted": + case "set_artmode_status": + case "get_content_list": + case "recently_set_updated": + case "preview_started": + case "preview_stopped": + case "favorite_changed": + case "content_list": + // do nothing + break; + case "get_artmode_settings": + logger.debug("{}: {}: {}", host, data.getEvent(), data.getData()); + msg = data.getData(); + if (!msg.isBlank()) { + JSONMessage.ArtmodeSettings[] artmodeSettings = remoteControllerWebSocket.gson.fromJson(msg, + JSONMessage.ArtmodeSettings[].class); + if (artmodeSettings != null) { + for (JSONMessage.ArtmodeSettings setting : artmodeSettings) { + // extract brightness and colour temperature here + if ("brightness".equals(setting.getItem())) { + valueReceived(ART_BRIGHTNESS, new PercentType(setting.getValue() * 10)); + } + if ("color_temperature".equals(setting.getItem())) { + valueReceived(ART_COLOR_TEMPERATURE, new DecimalType(setting.getValue())); + } + } + } } - remoteControllerWebSocket.updateCurrentApp(); break; + case "set_brightness": + case "brightness_changed": + case "brightness": + valueReceived(ART_BRIGHTNESS, new PercentType(data.getIntValue() * 10)); + break; + case "set_color_temperature": + case "color_temperature_changed": + case "color_temperature": + valueReceived(ART_COLOR_TEMPERATURE, new DecimalType(data.getIntValue())); + break; + case "get_artmode_status": + case "art_mode_changed": case "artmode_status": - logger.debug("artmode_status: {}", data.value); - if ("on".equals(data.value)) { + logger.debug("{}: {}: {}", host, data.getEvent(), data.getValue()); + if ("off".equals(data.getValue())) { + remoteControllerWebSocket.callback.powerUpdated(true, false); + remoteControllerWebSocket.callback.currentAppUpdated(""); + } else { remoteControllerWebSocket.callback.powerUpdated(false, true); + } + if (!remoteControllerWebSocket.noApps()) { + remoteControllerWebSocket.updateCurrentApp(); + } + break; + case "slideshow_image_changed": + case "slideshow_changed": + case "get_slideshow_status": + case "auto_rotation_changed": + case "auto_rotation_image_changed": + case "auto_rotation_status": + // value (duration) is "off" or "number" where number is duration in minutes + // data.type: "shuffleslideshow" or "slideshow" + // data.current_content_id: Current art displayed eg "MY_F0005" + // data.category_id: category eg 'MY-C0004' ie favouries or my Photos/shelf + if (!data.getValue().isBlank()) { + slideShowDuration = data.getValue(); + slideshow = !"off".equals(data.getValue()); + } + categoryId = (data.getCategoryId().isBlank()) ? categoryId : data.getCategoryId(); + if (!data.getContentId().isBlank() && slideshow) { + remoteControllerWebSocket.callback.currentAppUpdated( + String.format("%s %s %s", data.getType(), slideShowDuration, categoryId)); + } + logger.trace("{}: slideshow: {}, {}, {}, {}", host, data.getEvent(), data.getType(), + data.getValue(), data.getContentId()); + break; + case "image_added": + if (!data.getCategoryId().isBlank()) { + logger.debug("{}: Image added: {}, category: {}", host, data.getContentId(), + data.getCategoryId()); + } + break; + case "get_current_artwork": + case "select_image": + case "current_artwork": + case "image_selected": + // data.content_id: Current art displayed eg "MY_F0005" + // data.is_shown: "Yes" or "No" + if ("Yes".equals(data.getIsShown())) { + if (!slideshow) { + remoteControllerWebSocket.callback.currentAppUpdated("artMode"); + } + } + valueReceived(ART_LABEL, new StringType(data.getContentId())); + if (remoteControllerWebSocket.callback.handler.isChannelLinked(ART_IMAGE)) { + if (data.getEvent().contains("current_artwork") || "Yes".equals(data.getIsShown())) { + getThumbnail(data.getContentId()); + } + } + break; + case "get_thumbnail_list": + case "thumbnail": + logger.trace("{}: thumbnail: Fetching {}", host, data.getContentId()); + case "ready_to_use": + // upload image (should be 3840x2160 pixels in size) + msg = data.getConnInfo(); + if (!msg.isBlank()) { + JSONMessage.Contentinfo contentInfo = remoteControllerWebSocket.gson.fromJson(msg, + JSONMessage.Contentinfo.class); + if (contentInfo != null) { + // NOTE: do not tie up the websocket receive loop for too long, so use the scheduler + // upload image, or download thumbnail + scheduleSocketOperation(contentInfo, "ready_to_use".equals(data.getEvent())); + } } else { - remoteControllerWebSocket.callback.powerUpdated(true, false); + // <2019 (ish) Frame TV's return thumbnails as binary data + receiveThumbnail(jsonMsg.getBinaryData()); } - remoteControllerWebSocket.updateCurrentApp(); break; case "go_to_standby": - logger.debug("go_to_standby"); + logger.debug("{}: go_to_standby", host); remoteControllerWebSocket.callback.powerUpdated(false, false); + remoteControllerWebSocket.callback.setOffline(); + stateMap.clear(); break; case "wakeup": - logger.debug("wakeup"); + stateMap.clear(); + logger.debug("{}: wakeup from standby", host); // check artmode status to know complete status before updating getArtmodeStatus(); + getArtmodeStatus((getArtApiVersion() == 0) ? "get_auto_rotation_status" : "get_slideshow_status"); + getArtmodeStatus("get_current_artwork"); + getArtmodeStatus("get_color_temperature"); break; default: - logger.debug("Unknown d2d_service_message event: {}", msg); + logger.debug("{}: Unknown d2d_service_message event: {}", host, msg); } + } catch (JsonSyntaxException e) { + logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg); } } + public void valueReceived(String variable, State value) { + if (!stateMap.getOrDefault(variable, "").equals(value.toString())) { + remoteControllerWebSocket.callback.handler.valueReceived(variable, value); + stateMap.put(variable, value.toString()); + } else { + logger.trace("{}: Value '{}' for {} hasn't changed, ignoring update", host, value, variable); + } + } + + public int getArtApiVersion() { + return remoteControllerWebSocket.callback.handler.artApiVersion; + } + + public void setArtApiVersion(int apiVersion) { + remoteControllerWebSocket.callback.handler.artApiVersion = apiVersion; + } + + /** + * creates formatted json string for art websocket commands + * + * @param request Array of string requests to format + * + */ @NonNullByDefault({}) class JSONArtModeStatus { - public JSONArtModeStatus() { + public JSONArtModeStatus(String[] request) { Params.Data data = params.new Data(); - data.id = remoteControllerWebSocket.uuid.toString(); - params.data = remoteControllerWebSocket.gson.toJson(data); + if (request.length == 1) { + if (request[0].endsWith("}")) { + // full json request/command + request = request[0].split(","); + } else { + // send simple command in request[0] + data.request = request[0]; + params.data = remoteControllerWebSocket.gson.toJson(data); + return; + } + } + switch (request[0]) { + // predefined requests/commands + case "set_slideshow_status": + case "set_auto_rotation_status": + data.request = request[0]; + data.type = request[1]; + data.value = request[2]; + data.category_id = request[3]; + params.data = remoteControllerWebSocket.gson.toJson(data); + break; + case "set_brightness": + case "set_color_temperature": + data.request = request[0]; + data.value = request[1]; + params.data = remoteControllerWebSocket.gson.toJson(data); + break; + case "get_thumbnail": + data.request = request[0]; + data.content_id = request[1]; + data.conn_info = new Conninfo(); + params.data = remoteControllerWebSocket.gson.toJson(data); + break; + case "get_thumbnail_list": + connection_id_random++; + data.request = request[0]; + Content_id_list content_id = new Content_id_list(); + content_id.content_id = request[1]; + data.content_id_list = new Content_id_list[] { content_id }; + data.conn_info = new Conninfo(); + params.data = remoteControllerWebSocket.gson.toJson(data); + break; + case "select_image": + data.request = request[0]; + data.content_id = request[1]; + data.show = true; + params.data = remoteControllerWebSocket.gson.toJson(data); + break; + case "send_image": + RawType image = RawType.valueOf(request[1]); + fileType = image.getMimeType().split("/")[1]; + imageBytes = image.getBytes(); + data.request = request[0]; + data.request_id = remoteControllerWebSocket.uuid.toString(); + data.file_type = fileType; + data.conn_info = new Conninfo(); + data.image_date = DATEFORMAT.format(Instant.now()); + // data.matte_id = "flexible_polar"; + // data.portrait_matte_id = "flexible_polar"; + data.file_size = Long.valueOf(imageBytes.length); + params.data = remoteControllerWebSocket.gson.toJson(data); + break; + default: + // Just return formatted json (add id if needed) + if (Arrays.stream(request).anyMatch(a -> a.contains("\"id\""))) { + params.data = String.join(",", request).replace(",}", "}"); + } else { + ArrayList requestList = new ArrayList<>(Arrays.asList(request)); + requestList.add(requestList.size() - 1, + String.format("\"id\":\"%s\"", remoteControllerWebSocket.uuid.toString())); + params.data = String.join(",", requestList).replace(",}", "}"); + } + break; + } } - @NonNullByDefault({}) class Params { - @NonNullByDefault({}) class Data { String request = "get_artmode_status"; - String id; + String value; + String content_id; + @Nullable + Content_id_list[] content_id_list = null; + String category_id; + String type; + String file_type; + String image_date; + String matte_id; + Long file_size; + @Nullable + Boolean show = null; + String request_id; + String id = remoteControllerWebSocket.uuid.toString(); + Conninfo conn_info; } String event = "art_app_request"; @@ -153,11 +674,268 @@ class Data { String data; } + class Conninfo { + String d2d_mode = "socket"; + // this is a random number usually + // long connection_id = 2705890518L; + long connection_id = connection_id_random; + String request_id; + String id = remoteControllerWebSocket.uuid.toString(); + } + + class Content_id_list { + String content_id; + } + String method = "ms.channel.emit"; Params params = new Params(); } - void getArtmodeStatus() { - sendCommand(remoteControllerWebSocket.gson.toJson(new JSONArtModeStatus())); + public void getThumbnail(String content_id) { + if (!content_id.equals(lastThumbnail) || "NULL".equals(stateMap.getOrDefault(ART_IMAGE, "NULL"))) { + getArtmodeStatus((getArtApiVersion() == 0) ? "get_thumbnail" : "get_thumbnail_list", content_id); + lastThumbnail = content_id; + } else { + logger.trace("{}: NOT getting thumbnail for: {} as it hasn't changed", host, content_id); + } + } + + /** + * Extract header message from binary data + * <2019 (ish) Frame TV's return some messages as binary data + * First two bytes are a short giving the header length + * header is a D2DServiceMessages followed by the binary image data. + * + * Also Extract header information from image downloaded via socket + * in which case first four bytes are the header length followed by the binary image data. + * + * @param byte[] payload + * @param int offset (usually 0) + * @param int len + * @param boolean fromBinMsg true if this was received as a binary message (header length is a Short) + * + */ + public String extractMsg(byte[] payload, int offset, int len, boolean fromBinMsg) { + ByteBuffer buf = ByteBuffer.wrap(payload, offset, len); + int headerlen = fromBinMsg ? buf.getShort() : buf.getInt(); + offset += fromBinMsg ? Short.BYTES : Integer.BYTES; + String type = fromBinMsg ? "D2DServiceMessages(from binary)" : "image header"; + String header = new String(payload, offset, headerlen, StandardCharsets.UTF_8); + logger.trace("{}: Got {}: {}", host, type, header); + return header; + } + + /** + * Receive thumbnail from binary data returned by TV in response to get_thumbnail command + * <2019 (ish) Frame TV's return thumbnails as binary data. + * + * @param JSONMessage.BinaryData + * + */ + public void receiveThumbnail(JSONMessage.BinaryData binaryData) { + extractThumbnail(binaryData.getBinaryData(), binaryData.getLen() - binaryData.getOff()); + } + + /** + * Return a no-op SSL trust manager which will not verify server or client certificates. + */ + private TrustManager[] acceptAlltrustManagers() { + return new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) { + } + + @Override + public void checkServerTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) { + } + + @Override + public X509Certificate @Nullable [] getAcceptedIssuers() { + return null; + } + } }; + } + + public void scheduleSocketOperation(JSONMessage.Contentinfo contentInfo, boolean upload) { + logger.trace("{}: scheduled scheduleSocketOperation()", host); + remoteControllerWebSocket.callback.handler.getScheduler().schedule(() -> { + if (upload) { + uploadImage(contentInfo); + } else { + downloadThumbnail(contentInfo); + } + }, 50, TimeUnit.MILLISECONDS); + } + + /** + * Download thumbnail of current selected image/jpeg from ip+port + * + * @param contentinfo Contentinfo containing ip address and port to download from + * + */ + public void downloadThumbnail(JSONMessage.Contentinfo contentInfo) { + logger.trace("{}: thumbnail: downloading from: ip:{}, port:{}, secured:{}", host, contentInfo.getIp(), + contentInfo.getPort(), contentInfo.getSecured()); + try { + Socket socket; + if (contentInfo.getSecured()) { + if (sslsocketfactory != null) { + logger.trace("{}: thumbnail SSL socket connecting", host); + socket = sslsocketfactory.createSocket(contentInfo.getIp(), contentInfo.getPort()); + } else { + logger.debug("{}: sslsocketfactory is null", host); + return; + } + } else { + socket = new Socket(contentInfo.getIp(), contentInfo.getPort()); + } + if (socket != null) { + logger.trace("{}: thumbnail socket connected", host); + byte[] payload = Optional.ofNullable(socket.getInputStream().readAllBytes()).orElse(new byte[0]); + socket.close(); + if (payload.length > 0) { + String header = extractMsg(payload, 0, payload.length, false); + JSONMessage.Header headerData = Optional + .ofNullable(remoteControllerWebSocket.gson.fromJson(header, JSONMessage.Header.class)) + .orElse(new JSONMessage().new Header(0)); + extractThumbnail(payload, headerData.getFileLength()); + } else { + logger.trace("{}: thumbnail no data received", host); + valueReceived(ART_IMAGE, UnDefType.NULL); + } + } + } catch (IOException e) { + logger.warn("{}: Error downloading thumbnail {}", host, e.getMessage()); + } + } + + /** + * Extract thumbnail from payload + * + * @param payload byte[] containing binary data and possibly header info + * @param fileLength int with image file size + * + */ + public void extractThumbnail(byte[] payload, int fileLength) { + try { + byte[] image = new byte[fileLength]; + ByteBuffer.wrap(image).put(payload, payload.length - fileLength, fileLength); + String ftype = Optional + .ofNullable(URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(image))) + .orElseThrow(() -> new Exception("Unable to determine image type")); + valueReceived(ART_IMAGE, new RawType(image, ftype)); + } catch (Exception e) { + if (logger.isTraceEnabled()) { + logger.trace("{}: Error extracting thumbnail: ", host, e); + } else { + logger.warn("{}: Error extracting thumbnail {}", host, e.getMessage()); + } + } + } + + @NonNullByDefault({}) + @SuppressWarnings("unused") + private class JSONHeader { + public JSONHeader(int num, int total, long fileLength, String fileType, String secKey) { + this.num = num; + this.total = total; + this.fileLength = fileLength; + this.fileType = fileType; + this.secKey = secKey; + } + + int num = 0; + int total = 1; + long fileLength; + String fileName = "dummy"; + String fileType; + String secKey; + String version = "0.0.1"; + } + + /** + * Upload Image from ART_IMAGE/ART_LABEL channel + * + * @param contentinfo Contentinfo containing ip address, port and key to upload to + * + * imageBytes and fileType are class instance variables obtained from the + * getArtmodeStatus() command that triggered the upload. + * + */ + public void uploadImage(JSONMessage.Contentinfo contentInfo) { + logger.trace("{}: Uploading image to ip:{}, port:{}", host, contentInfo.getIp(), contentInfo.getPort()); + try { + Socket socket; + if (contentInfo.getSecured()) { + if (sslsocketfactory != null) { + logger.trace("{}: upload SSL socket connecting", host); + socket = (SSLSocket) sslsocketfactory.createSocket(contentInfo.getIp(), contentInfo.getPort()); + } else { + logger.debug("{}: sslsocketfactory is null", host); + return; + } + } else { + socket = new Socket(contentInfo.getIp(), contentInfo.getPort()); + } + if (socket != null) { + logger.trace("{}: upload socket connected", host); + DataOutputStream dataOutputStream = new DataOutputStream( + new BufferedOutputStream(socket.getOutputStream())); + String header = remoteControllerWebSocket.gson + .toJson(new JSONHeader(0, 1, imageBytes.length, fileType, contentInfo.getKey())); + logger.debug("{}: Image header: {}, {} bytes", host, header, header.length()); + dataOutputStream.writeInt(header.length()); + dataOutputStream.writeBytes(header); + dataOutputStream.write(imageBytes, 0, imageBytes.length); + dataOutputStream.flush(); + logger.debug("{}: wrote Image:{} {} bytes to TV", host, fileType, dataOutputStream.size()); + socket.close(); + } + } catch (IOException e) { + logger.warn("{}: Error writing image to TV {}", host, e.getMessage()); + } + } + + /** + * Set slideshow + * + * @param command split on ,space or + where + * + * First parameter is shuffleslideshow or slideshow + * Second is duration in minutes or off + * Third is category where the value is somethng like MY-C0004 = Favourites or MY-C0002 = My Photos. + * + */ + public void setSlideshow(String command) { + String[] cmd = command.split("[, +]"); + if (cmd.length <= 1) { + logger.warn("{}: Invalid slideshow command: {}", host, command); + return; + } + String value = ("0".equals(cmd[1])) ? "off" : cmd[1]; + categoryId = (cmd.length >= 3) ? cmd[2] : categoryId; + getArtmodeStatus((getArtApiVersion() == 0) ? "set_auto_rotation_status" : "set_slideshow_status", + cmd[0].toLowerCase(), value, categoryId); + } + + /** + * Send commands to Frame TV Art websocket channel + * + * @param optionalRequests Array of string requests + * + */ + void getArtmodeStatus(String... optionalRequests) { + if (optionalRequests.length == 0) { + optionalRequests = new String[] { "get_artmode_status" }; + } + if (getArtApiVersion() != 0) { + if ("get_brightness".equals(optionalRequests[0])) { + optionalRequests = new String[] { "get_artmode_settings" }; + } + if ("get_color_temperature".equals(optionalRequests[0])) { + optionalRequests = new String[] { "get_artmode_settings" }; + } + } + sendCommand(remoteControllerWebSocket.gson.toJson(new JSONArtModeStatus(optionalRequests))); } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketBase.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketBase.java old mode 100644 new mode 100755 index 80dc2607eb7d2..2493f4de4f2cf --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketBase.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketBase.java @@ -14,12 +14,16 @@ import java.io.IOException; import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Optional; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.WebSocketAdapter; +import org.eclipse.jetty.websocket.api.WebSocketPolicy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,56 +31,80 @@ * Websocket base class * * @author Arjan Mels - Initial contribution + * @author Nick Waterton - refactoring */ @NonNullByDefault class WebSocketBase extends WebSocketAdapter { private final Logger logger = LoggerFactory.getLogger(WebSocketBase.class); - /** - * - */ final RemoteControllerWebSocket remoteControllerWebSocket; + final int bufferSize = 1048576; // 1 Mb - private @Nullable Future sessionFuture; + private Optional> sessionFuture = Optional.empty(); + + private String host = ""; + private String className = ""; + private Optional uri = Optional.empty(); + private int count = 0; /** * @param remoteControllerWebSocket */ WebSocketBase(RemoteControllerWebSocket remoteControllerWebSocket) { this.remoteControllerWebSocket = remoteControllerWebSocket; + this.host = remoteControllerWebSocket.host; + this.className = this.getClass().getSimpleName(); } - boolean isConnecting = false; - @Override public void onWebSocketClose(int statusCode, @Nullable String reason) { - logger.debug("{} connection closed: {} - {}", this.getClass().getSimpleName(), statusCode, reason); + logger.debug("{}: {} connection closed: {} - {}", host, className, statusCode, reason); super.onWebSocketClose(statusCode, reason); - isConnecting = false; + if (statusCode == 1001) { + // timeout + reconnect(); + } + if (statusCode == 1006) { + // Disconnected + reconnect(); + } } @Override public void onWebSocketError(@Nullable Throwable error) { - if (logger.isTraceEnabled()) { - logger.trace("{} connection error", this.getClass().getSimpleName(), error); - } else { - logger.debug("{} connection error", this.getClass().getSimpleName()); - } + logger.debug("{}: {} connection error {}", host, className, error != null ? error.getMessage() : ""); super.onWebSocketError(error); - isConnecting = false; + } + + void reconnect() { + if (!isConnected()) { + if (sessionFuture.isPresent() && count++ < 4) { + uri.ifPresent(u -> { + try { + logger.debug("{}: Reconnecting : {} try: {}", host, className, count); + remoteControllerWebSocket.callback.handler.getScheduler().schedule(() -> { + reconnect(); + }, 2000, TimeUnit.MILLISECONDS); + connect(u); + } catch (RemoteControllerException e) { + logger.warn("{} Reconnect Failed {} : {}", host, className, e.getMessage()); + } + }); + } else { + count = 0; + } + } } void connect(URI uri) throws RemoteControllerException { - if (isConnecting || isConnected()) { - logger.trace("{} already connecting or connected", this.getClass().getSimpleName()); + count = 0; + if (isConnected() || sessionFuture.map(sf -> !sf.isDone()).orElse(false)) { + logger.trace("{}: {} already connecting or connected", host, className); return; } - - logger.debug("{} connecting to: {}", this.getClass().getSimpleName(), uri); - isConnecting = true; - + logger.debug("{}: {} connecting to: {}", host, className, uri); + this.uri = Optional.of(uri); try { - sessionFuture = remoteControllerWebSocket.client.connect(this, uri); - logger.trace("Connecting session Future: {}", sessionFuture); + sessionFuture = Optional.of(remoteControllerWebSocket.client.connect(this, uri)); } catch (IOException | IllegalStateException e) { throw new RemoteControllerException(e); } @@ -84,48 +112,64 @@ void connect(URI uri) throws RemoteControllerException { @Override public void onWebSocketConnect(@Nullable Session session) { - logger.debug("{} connection established: {}", this.getClass().getSimpleName(), + logger.debug("{}: {} connection established: {}", host, className, session != null ? session.getRemoteAddress().getHostString() : ""); + if (session != null) { + final WebSocketPolicy currentPolicy = session.getPolicy(); + currentPolicy.setInputBufferSize(bufferSize); + currentPolicy.setMaxTextMessageSize(bufferSize); + currentPolicy.setMaxBinaryMessageSize(bufferSize); + logger.trace("{}: {} Buffer Size set to {} Mb", host, className, + Math.round((bufferSize / 1048576.0) * 100.0) / 100.0); + // avoid 5 minute idle timeout + remoteControllerWebSocket.callback.handler.getScheduler().scheduleWithFixedDelay(() -> { + try { + String data = "Ping"; + ByteBuffer payload = ByteBuffer.wrap(data.getBytes()); + session.getRemote().sendPing(payload); + } catch (IOException e) { + logger.warn("{} problem starting periodic Ping {} : {}", host, className, e.getMessage()); + } + }, 4, 4, TimeUnit.MINUTES); + } super.onWebSocketConnect(session); - - isConnecting = false; + count = 0; } void close() { - logger.debug("{} connection close requested", this.getClass().getSimpleName()); - - Session session = getSession(); - if (session != null) { - session.close(); - } - - final Future sessionFuture = this.sessionFuture; - logger.trace("Closing session Future: {}", sessionFuture); - if (sessionFuture != null && !sessionFuture.isDone()) { - sessionFuture.cancel(true); - } + this.sessionFuture.ifPresent(sf -> { + if (!sf.isDone()) { + logger.trace("{}: Cancelling session Future: {}", host, sf); + sf.cancel(true); + } + }); + sessionFuture = Optional.empty(); + Optional.ofNullable(getSession()).ifPresent(s -> { + logger.debug("{}: {} Connection close requested", host, className); + s.close(); + }); } void sendCommand(String cmd) { try { if (isConnected()) { getRemote().sendString(cmd); - logger.trace("{}: sendCommand: {}", this.getClass().getSimpleName(), cmd); + logger.trace("{}: {}: sendCommand: {}", host, className, cmd); } else { - logger.warn("{} sending command while socket not connected: {}", this.getClass().getSimpleName(), cmd); - // retry opening connection just in case - remoteControllerWebSocket.openConnection(); - - getRemote().sendString(cmd); - logger.trace("{}: sendCommand: {}", this.getClass().getSimpleName(), cmd); + logger.warn("{}: {} not connected: {}", host, className, cmd); } - } catch (IOException | RemoteControllerException e) { - logger.warn("{}: cannot send command", this.getClass().getSimpleName(), e); + } catch (IOException e) { + logger.warn("{}: {}: cannot send command: {}", host, className, e.getMessage()); } } @Override public void onWebSocketText(@Nullable String str) { - logger.trace("{}: onWebSocketText: {}", this.getClass().getSimpleName(), str); + logger.trace("{}: {}: onWebSocketText: {}", host, className, str); + } + + @Override + public void onWebSocketBinary(byte @Nullable [] arr, int pos, int len) { + logger.trace("{}: {}: onWebSocketBinary: offset: {}, len: {}", host, className, pos, len); } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketRemote.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketRemote.java old mode 100644 new mode 100755 index 09d6cbaaa7db8..f7c06c262d812 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketRemote.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketRemote.java @@ -12,54 +12,74 @@ */ package org.openhab.binding.samsungtv.internal.protocol; -import java.util.stream.Collectors; +import static org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration.*; + +import java.util.Arrays; +import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration; -import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerWebSocket.App; +import org.openhab.binding.samsungtv.internal.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonSyntaxException; /** * Websocket class for remote control * * @author Arjan Mels - Initial contribution + * @author Nick Waterton - changes to sendKey(), some refactoring */ @NonNullByDefault class WebSocketRemote extends WebSocketBase { private final Logger logger = LoggerFactory.getLogger(WebSocketRemote.class); + private static Gson gson = new Gson(); + + private String host = ""; + private String className = ""; + private boolean mouseEnabled = false; + @SuppressWarnings("unused") @NonNullByDefault({}) - private static class JSONMessage { + public static class JSONMessage { String event; - @NonNullByDefault({}) static class App { String appId; String name; int app_type; + + public String getAppId() { + return Optional.ofNullable(appId).orElse(""); + } + + public String getName() { + return Optional.ofNullable(name).orElse(""); + } + + public int getAppType() { + return Optional.ofNullable(app_type).orElse(2); + } } - @NonNullByDefault({}) static class Data { String update_type; App[] data; - String id; String token; } - Data data; + // data is sometimes a json object, sometimes a string or number + JsonElement data; + Data newData; - @NonNullByDefault({}) static class Params { String params; - @NonNullByDefault({}) static class Data { String appId; } @@ -68,6 +88,34 @@ static class Data { } Params params; + + public String getEvent() { + return Optional.ofNullable(event).orElse(""); + } + + public Data getData() { + return Optional.ofNullable(data).map(a -> gson.fromJson(a, Data.class)).orElse(new Data()); + } + + public String getDataAsString() { + return Optional.ofNullable(data).map(a -> a.toString()).orElse(""); + } + + public App[] getAppData() { + return Optional.ofNullable(getData()).map(a -> a.data).orElse(new App[0]); + } + + public String getToken() { + return Optional.ofNullable(getData()).map(a -> a.token).orElse(""); + } + + public String getUpdateType() { + return Optional.ofNullable(getData()).map(a -> a.update_type).orElse(""); + } + + public String getAppId() { + return Optional.ofNullable(params).map(a -> a.data).map(a -> a.appId).orElse(""); + } } /** @@ -75,12 +123,13 @@ static class Data { */ WebSocketRemote(RemoteControllerWebSocket remoteControllerWebSocket) { super(remoteControllerWebSocket); + this.host = remoteControllerWebSocket.host; + this.className = this.getClass().getSimpleName(); } @Override public void onWebSocketError(@Nullable Throwable error) { super.onWebSocketError(error); - remoteControllerWebSocket.callback.connectionError(error); } @Override @@ -92,79 +141,108 @@ public void onWebSocketText(@Nullable String msgarg) { super.onWebSocketText(msg); try { JSONMessage jsonMsg = remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.class); - switch (jsonMsg.event) { + if (jsonMsg == null) { + return; + } + switch (jsonMsg.getEvent()) { case "ms.channel.connect": - logger.debug("Remote channel connected. Token = {}", jsonMsg.data.token); - if (jsonMsg.data.token != null) { - this.remoteControllerWebSocket.callback.putConfig(SamsungTvConfiguration.WEBSOCKET_TOKEN, - jsonMsg.data.token); + logger.debug("{}: Remote channel connected. Token = {}", host, jsonMsg.getToken()); + if (!jsonMsg.getToken().isBlank()) { + this.remoteControllerWebSocket.callback.putConfig(WEBSOCKET_TOKEN, jsonMsg.getToken()); // try opening additional websockets try { this.remoteControllerWebSocket.openConnection(); } catch (RemoteControllerException e) { - logger.warn("{}: Error ({})", this.getClass().getSimpleName(), e.getMessage()); + logger.warn("{}: {}: Error ({})", host, className, e.getMessage()); } } getApps(); break; case "ms.channel.clientConnect": - logger.debug("Remote client connected"); + logger.debug("{}: Another Remote client has connected", host); break; case "ms.channel.clientDisconnect": - logger.debug("Remote client disconnected"); + logger.debug("{}: Other Remote client has disconnected", host); + break; + case "ms.channel.timeOut": + logger.warn("{}: Remote Control Channel Timeout, SendKey/power commands are not available", host); break; + case "ms.channel.unauthorized": + logger.warn("{}: Remote Control is not authorized, please allow access on your TV", host); + break; + case "ms.remote.imeStart": + // Keyboard input start enable + break; + case "ms.remote.imeDone": + // keyboard input enabled + break; + case "ms.remote.imeUpdate": + // keyboard text selected (base64 format) is in data.toString() + // retrieve with getDataAsString() + break; + case "ms.remote.imeEnd": + // keyboard selection completed + break; + case "ms.remote.touchEnable": + logger.debug("{}: Mouse commands enabled", host); + mouseEnabled = true; + break; + case "ms.remote.touchDisable": + logger.debug("{}: Mouse commands disabled", host); + mouseEnabled = false; + break; + // note: the following 3 do not work on >2020 TV's case "ed.edenTV.update": - logger.debug("edenTV update: {}", jsonMsg.data.update_type); - remoteControllerWebSocket.updateCurrentApp(); + logger.debug("{}: edenTV update: {}", host, jsonMsg.getUpdateType()); + if ("ed.edenApp.update".equals(jsonMsg.getUpdateType())) { + remoteControllerWebSocket.updateCurrentApp(); + } break; case "ed.apps.launch": - logger.debug("App launched: {}", jsonMsg.params.data.appId); + logger.debug("{}: App launch: {}", host, + "200".equals(jsonMsg.getDataAsString()) ? "successfull" : "failed"); + if ("200".equals(jsonMsg.getDataAsString())) { + remoteControllerWebSocket.getAppStatus(""); + } + break; + case "ed.edenApp.get": break; case "ed.installedApp.get": handleInstalledApps(jsonMsg); break; default: - logger.debug("WebSocketRemote Unknown event: {}", msg); - + logger.debug("{}: WebSocketRemote Unknown event: {}", host, msg); } } catch (JsonSyntaxException e) { - logger.warn("{}: Error ({}) in message: {}", this.getClass().getSimpleName(), e.getMessage(), msg, e); + logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg); } } private void handleInstalledApps(JSONMessage jsonMsg) { remoteControllerWebSocket.apps.clear(); - - for (JSONMessage.App jsonApp : jsonMsg.data.data) { - App app = remoteControllerWebSocket.new App(jsonApp.appId, jsonApp.name, jsonApp.app_type); - remoteControllerWebSocket.apps.put(app.name, app); - } - if (logger.isDebugEnabled()) { - logger.debug("Installed Apps: {}", remoteControllerWebSocket.apps.entrySet().stream() - .map(entry -> entry.getValue().appId + " = " + entry.getKey()).collect(Collectors.joining(", "))); - } - + Arrays.stream(jsonMsg.getAppData()).forEach(a -> remoteControllerWebSocket.apps.put(a.getName(), + remoteControllerWebSocket.new App(a.getAppId(), a.getName(), a.getAppType()))); remoteControllerWebSocket.updateCurrentApp(); - } - - @NonNullByDefault({}) - static class JSONAppInfo { - @NonNullByDefault({}) - static class Params { - String event = "ed.installedApp.get"; - String to = "host"; - } - - String method = "ms.channel.emit"; - Params params = new Params(); + remoteControllerWebSocket.listApps(); } void getApps() { - sendCommand(remoteControllerWebSocket.gson.toJson(new JSONAppInfo())); + sendCommand(remoteControllerWebSocket.gson.toJson(new JSONSourceApp("ed.installedApp.get"))); } @NonNullByDefault({}) static class JSONSourceApp { + public JSONSourceApp(String event) { + this(event, ""); + } + + public JSONSourceApp(String event, String appId) { + params.event = event; + if (!appId.isBlank()) { + params.data.appId = appId; + } + } + public JSONSourceApp(String appName, boolean deepLink) { this(appName, deepLink, null); } @@ -175,9 +253,7 @@ public JSONSourceApp(String appName, boolean deepLink, String metaTag) { params.data.metaTag = metaTag; } - @NonNullByDefault({}) static class Params { - @NonNullByDefault({}) static class Data { String appId; String action_type; @@ -193,34 +269,66 @@ static class Data { Params params = new Params(); } - public void sendSourceApp(String appName, boolean deepLink) { - sendCommand(remoteControllerWebSocket.gson.toJson(new JSONSourceApp(appName, deepLink))); - } - - public void sendSourceApp(String appName, boolean deepLink, String metaTag) { + public void sendSourceApp(String appName, boolean deepLink, @Nullable String metaTag) { sendCommand(remoteControllerWebSocket.gson.toJson(new JSONSourceApp(appName, deepLink, metaTag))); } @NonNullByDefault({}) - static class JSONRemoteControl { - public JSONRemoteControl(boolean press, String key) { - params.Cmd = press ? "Press" : "Click"; - params.DataOfCmd = key; + class JSONRemoteControl { + public JSONRemoteControl(String action, String value) { + switch (action) { + case "Move": + params.Cmd = action; + // {"x": x, "y": y, "Time": str(duration)} + params.Position = remoteControllerWebSocket.gson.fromJson(value, location.class); + params.TypeOfRemote = "ProcessMouseDevice"; + break; + case "MouseClick": + params.Cmd = value; + params.TypeOfRemote = "ProcessMouseDevice"; + break; + case "Click": + case "Press": + case "Release": + params.Cmd = action; + params.DataOfCmd = value; + params.Option = "false"; + params.TypeOfRemote = "SendRemoteKey"; + break; + case "End": + params.TypeOfRemote = "SendInputEnd"; + break; + case "Text": + params.Cmd = Utils.b64encode(value); + params.DataOfCmd = "base64"; + params.TypeOfRemote = "SendInputString"; + break; + } } - @NonNullByDefault({}) - static class Params { + class location { + int x; + int y; + String Time; + } + + class Params { String Cmd; String DataOfCmd; - String Option = "false"; - String TypeOfRemote = "SendRemoteKey"; + location Position; + String Option; + String TypeOfRemote; } String method = "ms.remote.control"; Params params = new Params(); } - void sendKeyData(boolean press, String key) { - sendCommand(remoteControllerWebSocket.gson.toJson(new JSONRemoteControl(press, key))); + void sendKeyData(String action, String key) { + if (!mouseEnabled && ("Move".equals(action) || "MouseClick".equals(action))) { + logger.warn("{}: Mouse actions are not enabled for this app", host); + return; + } + sendCommand(remoteControllerWebSocket.gson.toJson(new JSONRemoteControl(action, key))); } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketV2.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketV2.java old mode 100644 new mode 100755 index 23fbfe591b644..7e5a1a62f8f99 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketV2.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/protocol/WebSocketV2.java @@ -12,6 +12,9 @@ */ package org.openhab.binding.samsungtv.internal.protocol; +import java.util.Optional; +import java.util.stream.Stream; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.slf4j.Logger; @@ -23,34 +26,69 @@ * Websocket class to retrieve app status * * @author Arjan Mels - Initial contribution + * @author Nick Waterton - Updated to handle >2020 TV's */ @NonNullByDefault class WebSocketV2 extends WebSocketBase { private final Logger logger = LoggerFactory.getLogger(WebSocketV2.class); + private String host = ""; + private String className = ""; + // temporary storage for source appId. + String currentSourceApp = ""; + WebSocketV2(RemoteControllerWebSocket remoteControllerWebSocket) { super(remoteControllerWebSocket); + this.host = remoteControllerWebSocket.host; + this.className = this.getClass().getSimpleName(); + } + + @SuppressWarnings("unused") + @NonNullByDefault({}) + private static class JSONAcq { + String id; + boolean result; + + static class Error { + String code; + String details; + String message; + String status; + } + + Error error; + + public String getId() { + return Optional.ofNullable(id).orElse(""); + } + + public boolean getResult() { + return Optional.ofNullable(result).orElse(false); + } + + public String getErrorCode() { + return Optional.ofNullable(error).map(a -> a.code).orElse(""); + } } @SuppressWarnings("unused") @NonNullByDefault({}) private static class JSONMessage { String event; + String id; - @NonNullByDefault({}) static class Result { String id; String name; + String running; String visible; } - @NonNullByDefault({}) static class Data { String id; String token; } - @NonNullByDefault({}) static class Error { String code; String details; @@ -61,6 +99,22 @@ static class Error { Result result; Data data; Error error; + + public String getEvent() { + return Optional.ofNullable(event).orElse(""); + } + + public String getName() { + return Optional.ofNullable(result).map(a -> a.name).orElse(""); + } + + public String getId() { + return Optional.ofNullable(result).map(a -> a.id).orElse(""); + } + + public String getVisible() { + return Optional.ofNullable(result).map(a -> a.visible).orElse(""); + } } @Override @@ -70,79 +124,198 @@ public void onWebSocketText(@Nullable String msgarg) { } String msg = msgarg.replace('\n', ' '); super.onWebSocketText(msg); + try { + JSONAcq jsonAcq = this.remoteControllerWebSocket.gson.fromJson(msg, JSONAcq.class); + if (jsonAcq != null && !jsonAcq.getId().isBlank()) { + if (jsonAcq.getResult()) { + // 3 second delay as app does not report visible until then. + remoteControllerWebSocket.getAppStatus(jsonAcq.getId()); + } + if (!jsonAcq.getErrorCode().isBlank()) { + if ("404".equals(jsonAcq.getErrorCode())) { + // remove app from manual list if it's not installed using message id. + removeApp(jsonAcq.getId()); + } + } + return; + } + } catch (JsonSyntaxException ignore) { + // ignore error + } try { JSONMessage jsonMsg = this.remoteControllerWebSocket.gson.fromJson(msg, JSONMessage.class); - - if (jsonMsg.result != null) { - handleResult(jsonMsg); + if (jsonMsg == null) { return; } - if (jsonMsg.error != null) { - logger.debug("WebSocketV2 Error received: {}", msg); + if (!jsonMsg.getId().isBlank()) { + handleResult(jsonMsg); return; } - if (jsonMsg.event == null) { - logger.debug("WebSocketV2 Unknown response format: {}", msg); + if (jsonMsg.error != null) { + logger.debug("{}: WebSocketV2 Error received: {}", host, msg); return; } - switch (jsonMsg.event) { + switch (jsonMsg.getEvent()) { case "ms.channel.connect": - logger.debug("V2 channel connected. Token = {}", jsonMsg.data.token); + logger.debug("{}: V2 channel connected. Token = {}", host, jsonMsg.data.token); // update is requested from ed.installedApp.get event: small risk that this websocket is not // yet connected + // on >2020 TV's this doesn't work so samsungTvAppWatchService should kick in automatically break; case "ms.channel.clientConnect": - logger.debug("V2 client connected"); + logger.debug("{}: V2 client connected", host); break; case "ms.channel.clientDisconnect": - logger.debug("V2 client disconnected"); + logger.debug("{}: V2 client disconnected", host); break; default: - logger.debug("V2 Unknown event: {}", msg); + logger.debug("{}: V2 Unknown event: {}", host, msg); } } catch (JsonSyntaxException e) { - logger.warn("{}: Error ({}) in message: {}", this.getClass().getSimpleName(), e.getMessage(), msg, e); + logger.warn("{}: {}: Error ({}) in message: {}", host, className, e.getMessage(), msg); } } - private void handleResult(JSONMessage jsonMsg) { - if ((remoteControllerWebSocket.currentSourceApp == null - || remoteControllerWebSocket.currentSourceApp.trim().isEmpty()) - && "true".equals(jsonMsg.result.visible)) { - logger.debug("Running app: {} = {}", jsonMsg.result.id, jsonMsg.result.name); - remoteControllerWebSocket.currentSourceApp = jsonMsg.result.name; - remoteControllerWebSocket.callback.currentAppUpdated(remoteControllerWebSocket.currentSourceApp); + /** + * Handle results of getappstatus response, updates current running app channel + */ + private synchronized void handleResult(JSONMessage jsonMsg) { + if (remoteControllerWebSocket.noApps()) { + updateApps(jsonMsg); } - - if (remoteControllerWebSocket.lastApp != null && remoteControllerWebSocket.lastApp.equals(jsonMsg.result.id)) { - if (remoteControllerWebSocket.currentSourceApp == null - || remoteControllerWebSocket.currentSourceApp.trim().isEmpty()) { - remoteControllerWebSocket.callback.currentAppUpdated(""); - } - remoteControllerWebSocket.lastApp = null; + if (!jsonMsg.getName().isBlank() && "true".equals(jsonMsg.getVisible())) { + logger.debug("{}: Running app: {} = {}", host, jsonMsg.getId(), jsonMsg.getName()); + currentSourceApp = jsonMsg.getId(); + remoteControllerWebSocket.callback.currentAppUpdated(jsonMsg.getName()); + } + if (currentSourceApp.equals(jsonMsg.getId()) && "false".equals(jsonMsg.getVisible())) { + currentSourceApp = ""; + remoteControllerWebSocket.callback.currentAppUpdated(""); } } @NonNullByDefault({}) - static class JSONAppStatus { - public JSONAppStatus(String id) { + class JSONApp { + public JSONApp(String id, String method) { + this(id, method, null); + } + + public JSONApp(String id, String method, @Nullable String metaTag) { + // use message id to identify app to remove this.id = id; + this.method = method; params.id = id; + // not working + params.metaTag = metaTag; } - @NonNullByDefault({}) - static class Params { + class Params { String id; + String metaTag; } - String method = "ms.application.get"; + String method; String id; Params params = new Params(); } + /** + * update manApp.name if it's incorrect + */ + void updateApps(JSONMessage jsonMsg) { + remoteControllerWebSocket.manApps.values().stream() + .filter(a -> a.getAppId().equals(jsonMsg.getId()) && !a.getName().equals(jsonMsg.getName())) + .peek(a -> logger.trace("{}: Updated app name {} to: {}", host, a.getName(), jsonMsg.getName())) + .findFirst().ifPresent(a -> a.setName(jsonMsg.getName())); + + updateApp(jsonMsg); + } + + /** + * Fix app key, if it's the app id + */ + @SuppressWarnings("null") + void updateApp(JSONMessage jsonMsg) { + if (remoteControllerWebSocket.manApps.containsKey(jsonMsg.getId())) { + int type = remoteControllerWebSocket.manApps.get(jsonMsg.getId()).getType(); + remoteControllerWebSocket.manApps.put(jsonMsg.getName(), + remoteControllerWebSocket.new App(jsonMsg.getId(), jsonMsg.getName(), type)); + remoteControllerWebSocket.manApps.remove(jsonMsg.getId()); + logger.trace("{}: Updated app id {} name to: {}", host, jsonMsg.getId(), jsonMsg.getName()); + remoteControllerWebSocket.updateCount = 0; + } + } + + /** + * Send get application status + * + * @param id appId of app to get status for + */ void getAppStatus(String id) { - sendCommand(remoteControllerWebSocket.gson.toJson(new JSONAppStatus(id))); + if (!id.isEmpty()) { + boolean appType = getAppStream().filter(a -> a.getAppId().equals(id)).map(a -> a.getType() == 2).findFirst() + .orElse(true); + // note apptype 4 always seems to return an error, so use default of 2 (true) + String apptype = (appType) ? "ms.application.get" : "ms.webapplication.get"; + sendCommand(remoteControllerWebSocket.gson.toJson(new JSONApp(id, apptype))); + } + } + + /** + * Closes current app if one is open + * + * @return false if no app was running, true if an app was closed + */ + public boolean closeApp() { + return getAppStream().filter(a -> a.appId.equals(currentSourceApp)) + .peek(a -> logger.debug("{}: closing app: {}", host, a.getName())) + .map(a -> closeApp(a.getAppId(), a.getType() == 2)).findFirst().orElse(false); + } + + public boolean closeApp(String appId, boolean appType) { + String apptype = (appType) ? "ms.application.stop" : "ms.webapplication.stop"; + sendCommand(remoteControllerWebSocket.gson.toJson(new JSONApp(appId, apptype))); + return true; + } + + public void removeApp(String id) { + remoteControllerWebSocket.manApps.values().removeIf(app -> app.getAppId().equals(id)); + } + + public Stream getAppStream() { + return (remoteControllerWebSocket.noApps()) ? remoteControllerWebSocket.manApps.values().stream() + : remoteControllerWebSocket.apps.values().stream(); + } + + /** + * Launches app by appId, closes current app if sent "" + * adds app if it's missing from manApps + * + * @param id AppId to launch + * @param type (2 or 4) + * @param metaTag optional url to launch (not working) + */ + public void sendSourceApp(String id, boolean type, @Nullable String metaTag) { + if (!id.isBlank()) { + if (id.equals(currentSourceApp)) { + logger.debug("{}: {} already running", host, id); + return; + } + if ("org.tizen.browser".equals(id) && remoteControllerWebSocket.noApps()) { + logger.warn("{}: using {} - you need a correct entry for \"Internet\" in the appslist file", host, id); + } + if (!getAppStream().anyMatch(a -> a.getAppId().equals(id))) { + logger.debug("{}: Adding App : {}", host, id); + remoteControllerWebSocket.manApps.put(id, remoteControllerWebSocket.new App(id, id, (type) ? 2 : 4)); + } + String apptype = (type) ? "ms.application.start" : "ms.webapplication.start"; + sendCommand(remoteControllerWebSocket.gson.toJson(new JSONApp(id, apptype, metaTag))); + } else { + if (!closeApp()) { + remoteControllerWebSocket.sendKeyPress(KeyCode.KEY_EXIT, 2000); + } + } } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/DataConverters.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/DataConverters.java deleted file mode 100644 index 49d7e9c7b2b70..0000000000000 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/DataConverters.java +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.samsungtv.internal.service; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.IncreaseDecreaseType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.OpenClosedType; -import org.openhab.core.library.types.UpDownType; -import org.openhab.core.types.Command; - -/** - * The {@link DataConverters} provides utils for converting openHAB commands to - * Samsung TV specific values. - * - * @author Pauli Anttila - Initial contribution - */ -@NonNullByDefault -public class DataConverters { - - /** - * Convert openHAB command to int. - * - * @param command - * @param min - * @param max - * @param currentValue - * @return - */ - public static int convertCommandToIntValue(Command command, int min, int max, int currentValue) { - if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) { - int value; - if (command instanceof IncreaseDecreaseType && command == IncreaseDecreaseType.INCREASE) { - value = Math.min(max, currentValue + 1); - } else if (command instanceof IncreaseDecreaseType && command == IncreaseDecreaseType.DECREASE) { - value = Math.max(min, currentValue - 1); - } else if (command instanceof DecimalType decimalCommand) { - value = decimalCommand.intValue(); - } else { - throw new NumberFormatException("Command '" + command + "' not supported"); - } - - return value; - - } else { - throw new NumberFormatException("Command '" + command + "' not supported"); - } - } - - /** - * Convert openHAB command to boolean. - * - * @param command - * @return - */ - public static boolean convertCommandToBooleanValue(Command command) { - if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) { - boolean newValue; - - if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) { - newValue = true; - } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN) - || command.equals(OpenClosedType.CLOSED)) { - newValue = false; - } else { - throw new NumberFormatException("Command '" + command + "' not supported"); - } - - return newValue; - - } else { - throw new NumberFormatException("Command '" + command + "' not supported for channel"); - } - } -} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MainTVServerService.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MainTVServerService.java old mode 100644 new mode 100755 index 1b989530e6970..7e69a1839e5b2 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MainTVServerService.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MainTVServerService.java @@ -14,85 +14,102 @@ import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.samsungtv.internal.service.api.EventListener; +import org.openhab.binding.samsungtv.internal.Utils; +import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler; import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; import org.openhab.core.io.transport.upnp.UpnpIOParticipant; import org.openhab.core.io.transport.upnp.UpnpIOService; import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.StringType; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; -import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; import org.w3c.dom.Element; -import org.w3c.dom.NodeList; +import org.w3c.dom.Node; /** * The {@link MainTVServerService} is responsible for handling MainTVServer * commands. * * @author Pauli Anttila - Initial contribution + * @author Nick Waterton - add checkConnection(), getServiceName, some refactoring */ @NonNullByDefault public class MainTVServerService implements UpnpIOParticipant, SamsungTvService { public static final String SERVICE_NAME = "MainTVServer2"; - private static final List SUPPORTED_CHANNELS = Arrays.asList(CHANNEL_NAME, CHANNEL, SOURCE_NAME, SOURCE_ID, - PROGRAM_TITLE, BROWSER_URL, STOP_BROWSER); - + private static final String SERVICE_MAIN_AGENT = "MainTVAgent2"; + private static final List SUPPORTED_CHANNELS = List.of(SOURCE_NAME, SOURCE_ID, BROWSER_URL, STOP_BROWSER); + private static final List REFRESH_CHANNELS = List.of(CHANNEL, SOURCE_NAME, SOURCE_ID, PROGRAM_TITLE, + CHANNEL_NAME, BROWSER_URL); + private static final List SUBSCRIPTION_REFRESH_CHANNELS = List.of(SOURCE_NAME); + protected static final int SUBSCRIPTION_DURATION = 1800; private final Logger logger = LoggerFactory.getLogger(MainTVServerService.class); private final UpnpIOService service; private final String udn; + private String host = ""; - private Map stateMap = Collections.synchronizedMap(new HashMap<>()); + private final SamsungTvHandler handler; - private Set listeners = new CopyOnWriteArraySet<>(); + private Map stateMap = Collections.synchronizedMap(new HashMap<>()); + private Map sources = Collections.synchronizedMap(new HashMap<>()); private boolean started; + private boolean subscription; - public MainTVServerService(UpnpIOService upnpIOService, String udn) { - logger.debug("Creating a Samsung TV MainTVServer service"); + public MainTVServerService(UpnpIOService upnpIOService, String udn, String host, SamsungTvHandler handler) { this.service = upnpIOService; this.udn = udn; + this.handler = handler; + this.host = host; + logger.debug("{}: Creating a Samsung TV MainTVServer service: subscription={}", host, getSubscription()); } - @Override - public List getSupportedChannelNames() { - return SUPPORTED_CHANNELS; + private boolean getSubscription() { + return handler.configuration.getSubscription(); } @Override - public void addEventListener(EventListener listener) { - listeners.add(listener); + public String getServiceName() { + return SERVICE_NAME; } @Override - public void removeEventListener(EventListener listener) { - listeners.remove(listener); + public List getSupportedChannelNames(boolean refresh) { + if (refresh) { + if (subscription) { + return SUBSCRIPTION_REFRESH_CHANNELS; + } + return REFRESH_CHANNELS; + } + logger.trace("{}: getSupportedChannelNames: {}", host, SUPPORTED_CHANNELS); + return SUPPORTED_CHANNELS; } @Override public void start() { service.registerParticipant(this); + addSubscription(); started = true; } @Override public void stop() { + removeSubscription(); service.unregisterParticipant(this); started = false; } @@ -100,6 +117,7 @@ public void stop() { @Override public void clearCache() { stateMap.clear(); + sources.clear(); } @Override @@ -108,54 +126,76 @@ public boolean isUpnp() { } @Override - public void handleCommand(String channel, Command command) { - logger.trace("Received channel: {}, command: {}", channel, command); + public boolean checkConnection() { + return started; + } - if (!started) { - return; + @Override + public boolean handleCommand(String channel, Command command) { + logger.trace("{}: Received channel: {}, command: {}", host, channel, command); + boolean result = false; + + if (!checkConnection()) { + return false; } if (command == RefreshType.REFRESH) { if (isRegistered()) { switch (channel) { case CHANNEL: - updateResourceState("MainTVAgent2", "GetCurrentMainTVChannel", null); + updateResourceState("GetCurrentMainTVChannel"); break; case SOURCE_NAME: case SOURCE_ID: - updateResourceState("MainTVAgent2", "GetCurrentExternalSource", null); + updateResourceState("GetCurrentExternalSource"); break; case PROGRAM_TITLE: case CHANNEL_NAME: - updateResourceState("MainTVAgent2", "GetCurrentContentRecognition", null); + updateResourceState("GetCurrentContentRecognition"); break; case BROWSER_URL: - updateResourceState("MainTVAgent2", "GetCurrentBrowserURL", null); + updateResourceState("GetCurrentBrowserURL"); break; default: break; } } - return; + return true; } switch (channel) { + case SOURCE_ID: + if (command instanceof DecimalType) { + command = new StringType(command.toString()); + } case SOURCE_NAME: - setSourceName(command); - // Clear value on cache to force update - stateMap.put("CurrentExternalSource", ""); + if (command instanceof StringType) { + result = setSourceName(command); + updateResourceState("GetCurrentExternalSource"); + } break; case BROWSER_URL: - setBrowserUrl(command); - // Clear value on cache to force update - stateMap.put("BrowserURL", ""); + if (command instanceof StringType) { + result = setBrowserUrl(command); + } break; case STOP_BROWSER: - stopBrowser(command); + if (command instanceof OnOffType) { + // stop browser if command is On or Off + result = stopBrowser(); + if (result) { + onValueReceived("BrowserURL", "", SERVICE_MAIN_AGENT); + } + } break; default: - logger.warn("Samsung TV doesn't support transmitting for channel '{}'", channel); + logger.warn("{}: Samsung TV doesn't support send for channel '{}'", host, channel); + return false; + } + if (!result) { + logger.warn("{}: main tvservice: command error {} channel {}", host, command, channel); } + return result; } private boolean isRegistered() { @@ -167,170 +207,205 @@ public String getUDN() { return udn; } + private void addSubscription() { + // Set up GENA Subscriptions + if (isRegistered() && getSubscription()) { + logger.debug("{}: Subscribing to service {}...", host, SERVICE_MAIN_AGENT); + service.addSubscription(this, SERVICE_MAIN_AGENT, SUBSCRIPTION_DURATION); + } + } + + private void removeSubscription() { + // Remove GENA Subscriptions + if (isRegistered() && subscription) { + logger.debug("{}: Unsubscribing from service {}...", host, SERVICE_MAIN_AGENT); + service.removeSubscription(this, SERVICE_MAIN_AGENT); + } + } + @Override public void onServiceSubscribed(@Nullable String service, boolean succeeded) { + if (service == null) { + return; + } + subscription = succeeded; + logger.debug("{}: Subscription to service {} {}", host, service, succeeded ? "succeeded" : "failed"); } @Override public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) { - if (variable == null) { + if (variable == null || value == null || service == null || variable.isBlank()) { return; } - String oldValue = stateMap.get(variable); - if ((value == null && oldValue == null) || (value != null && value.equals(oldValue))) { - logger.trace("Value '{}' for {} hasn't changed, ignoring update", value, variable); + variable = variable.replace("Current", ""); + String oldValue = stateMap.getOrDefault(variable, "None"); + if (value.equals(oldValue)) { + logger.trace("{}: Value '{}' for {} hasn't changed, ignoring update", host, value, variable); return; } - stateMap.put(variable, (value != null) ? value : ""); + stateMap.put(variable, value); - for (EventListener listener : listeners) { - - switch (variable) { - case "ProgramTitle": - listener.valueReceived(PROGRAM_TITLE, (value != null) ? new StringType(value) : UnDefType.UNDEF); - break; - case "ChannelName": - listener.valueReceived(CHANNEL_NAME, (value != null) ? new StringType(value) : UnDefType.UNDEF); - break; - case "CurrentExternalSource": - listener.valueReceived(SOURCE_NAME, (value != null) ? new StringType(value) : UnDefType.UNDEF); - break; - case "CurrentChannel": - String currentChannel = (value != null) ? parseCurrentChannel(value) : null; - listener.valueReceived(CHANNEL, - currentChannel != null ? new DecimalType(currentChannel) : UnDefType.UNDEF); - break; - case "ID": - listener.valueReceived(SOURCE_ID, (value != null) ? new DecimalType(value) : UnDefType.UNDEF); - break; - case "BrowserURL": - listener.valueReceived(BROWSER_URL, (value != null) ? new StringType(value) : UnDefType.UNDEF); - break; - } + switch (variable) { + case "A_ARG_TYPE_LastChange": + parseEventValues(value); + break; + case "ProgramTitle": + handler.valueReceived(PROGRAM_TITLE, new StringType(value)); + break; + case "ChannelName": + handler.valueReceived(CHANNEL_NAME, new StringType(value)); + break; + case "ExternalSource": + handler.valueReceived(SOURCE_NAME, new StringType(value)); + break; + case "MajorCh": + handler.valueReceived(CHANNEL, new DecimalType(value)); + break; + case "ID": + handler.valueReceived(SOURCE_ID, new DecimalType(value)); + break; + case "BrowserURL": + handler.valueReceived(BROWSER_URL, new StringType(value)); + break; } } - protected Map updateResourceState(String serviceId, String actionId, - @Nullable Map inputs) { - Map result = service.invokeAction(this, serviceId, actionId, inputs); + protected Map updateResourceState(String actionId) { + return updateResourceState(actionId, Map.of()); + } - for (String variable : result.keySet()) { - onValueReceived(variable, result.get(variable), serviceId); + protected synchronized Map updateResourceState(String actionId, Map inputs) { + Map result = Optional.of(service) + .map(a -> a.invokeAction(this, SERVICE_MAIN_AGENT, actionId, inputs)).filter(a -> !a.isEmpty()) + .orElse(Map.of("Result", "Command Failed")); + if (isOk(result)) { + result.keySet().stream().filter(a -> !"Result".equals(a)).forEach(a -> { + String val = result.getOrDefault(a, ""); + if ("CurrentChannel".equals(a)) { + val = parseCurrentChannel(val); + a = "MajorCh"; + } + onValueReceived(a, val, SERVICE_MAIN_AGENT); + }); } - return result; } - private void setSourceName(Command command) { - Map result = updateResourceState("MainTVAgent2", "GetSourceList", null); - - String source = command.toString(); - String id = null; - - String resultResult = result.get("Result"); - if ("OK".equals(resultResult)) { - String xml = result.get("SourceList"); - if (xml != null) { - id = parseSourceList(xml).get(source); - } - } else { - logger.warn("Source list query failed, result='{}'", resultResult); - } - - if (source != null && id != null) { - result = updateResourceState("MainTVAgent2", "SetMainTVSource", - SamsungTvUtils.buildHashMap("Source", source, "ID", id, "UiID", "0")); + public boolean isOk(Map result) { + return result.getOrDefault("Result", "Error").equals("OK"); + } - resultResult = result.get("Result"); - if ("OK".equals(resultResult)) { - logger.debug("Command successfully executed"); - } else { - logger.warn("Command execution failed, result='{}'", resultResult); - } - } else { - logger.warn("Source id for '{}' couldn't be found", command.toString()); + /** + * Searches sources for source, or ID, and sets TV input to that value + */ + private boolean setSourceName(Command command) { + String tmpSource = command.toString(); + if (sources.isEmpty()) { + getSourceMap(); } + String source = sources.entrySet().stream().filter(a -> a.getValue().equals(tmpSource)).map(a -> a.getKey()) + .findFirst().orElse(tmpSource); + Map result = updateResourceState("SetMainTVSource", + Map.of("Source", source, "ID", sources.getOrDefault(source, "0"), "UiID", "0")); + logResult(result.getOrDefault("Result", "Unable to Set Source Name: " + source)); + return isOk(result); } - private void setBrowserUrl(Command command) { - Map result = updateResourceState("MainTVAgent2", "RunBrowser", - SamsungTvUtils.buildHashMap("BrowserURL", command.toString())); - - String resultResult = result.get("Result"); - if ("OK".equals(resultResult)) { - logger.debug("Command successfully executed"); - } else { - logger.warn("Command execution failed, result='{}'", resultResult); - } + private boolean setBrowserUrl(Command command) { + Map result = updateResourceState("RunBrowser", Map.of("BrowserURL", command.toString())); + logResult(result.getOrDefault("Result", "Unable to Set browser URL: " + command.toString())); + return isOk(result); } - private void stopBrowser(Command command) { - Map result = updateResourceState("MainTVAgent2", "StopBrowser", null); + private boolean stopBrowser() { + Map result = updateResourceState("StopBrowser"); + logResult(result.getOrDefault("Result", "Unable to Stop Browser")); + return isOk(result); + } - String resultResult = result.get("Result"); - if ("OK".equals(resultResult)) { - logger.debug("Command successfully executed"); + private void logResult(String ok) { + if ("OK".equals(ok)) { + logger.debug("{}: Command successfully executed", host); } else { - logger.warn("Command execution failed, result='{}'", resultResult); + logger.warn("{}: Command execution failed, result='{}'", host, ok); } } - private @Nullable String parseCurrentChannel(@Nullable String xml) { - String majorCh = null; - - if (xml != null) { - Document dom = SamsungTvUtils.loadXMLFromString(xml); - - if (dom != null) { - NodeList nodeList = dom.getDocumentElement().getElementsByTagName("MajorCh"); - - if (nodeList != null) { - majorCh = nodeList.item(0).getFirstChild().getNodeValue(); - } - } - } - - return majorCh; + private String parseCurrentChannel(String xml) { + return Utils.loadXMLFromString(xml, host).map(a -> a.getDocumentElement()) + .map(a -> getFirstNodeValue(a, "MajorCh", "-1")).orElse("-1"); } - private Map parseSourceList(String xml) { - Map list = new HashMap<>(); - - Document dom = SamsungTvUtils.loadXMLFromString(xml); - - if (dom != null) { - NodeList nodeList = dom.getDocumentElement().getElementsByTagName("Source"); + private void getSourceMap() { + // NodeList doesn't have a stream, so do this + sources = Optional.of(updateResourceState("GetSourceList")).filter(a -> "OK".equals(a.get("Result"))) + .map(a -> a.get("SourceList")).flatMap(xml -> Utils.loadXMLFromString(xml, host)) + .map(a -> a.getDocumentElement()).map(a -> a.getElementsByTagName("Source")) + .map(nList -> IntStream.range(0, nList.getLength()).boxed().map(i -> (Element) nList.item(i)) + .collect(Collectors.toMap(a -> getFirstNodeValue(a, "SourceType", ""), + a -> getFirstNodeValue(a, "ID", "")))) + .orElse(Map.of()); + } - if (nodeList != null) { - for (int i = 0; i < nodeList.getLength(); i++) { + private String getFirstNodeValue(Element nodeList, String node, String ifNone) { + return Optional.ofNullable(nodeList).map(a -> a.getElementsByTagName(node)).filter(a -> a.getLength() > 0) + .map(a -> a.item(0)).map(a -> a.getTextContent()).orElse(ifNone); + } - String sourceType = null; - String id = null; + /** + * Parse Subscription Event from {@link String} which contains XML content. + * Parses all child Nodes recursively. + * If valid channel update is found, call onValueReceived() + * + * @param xml{@link String} which contains XML content. + */ + public void parseEventValues(String xml) { + Utils.loadXMLFromString(xml, host).ifPresent(a -> visitRecursively(a)); + } - Element element = (Element) nodeList.item(i); - NodeList l = element.getElementsByTagName("SourceType"); - if (l != null && l.getLength() > 0) { - sourceType = l.item(0).getFirstChild().getNodeValue(); - } - l = element.getElementsByTagName("ID"); - if (l != null && l.getLength() > 0) { - id = l.item(0).getFirstChild().getNodeValue(); - } + public void visitRecursively(Node node) { + // get all child nodes, NodeList doesn't have a stream, so do this + Optional.ofNullable(node.getChildNodes()).ifPresent(nList -> IntStream.range(0, nList.getLength()) + .mapToObj(i -> (Node) nList.item(i)).forEach(childNode -> parseNode(childNode))); + } - if (sourceType != null && id != null) { - list.put(sourceType, id); + public void parseNode(Node node) { + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element el = (Element) node; + switch (el.getNodeName()) { + case "BrowserChanged": + if ("Disable".equals(el.getTextContent())) { + onValueReceived("BrowserURL", "", SERVICE_MAIN_AGENT); + } else { + updateResourceState("GetCurrentBrowserURL"); } - } + break; + case "PowerOFF": + logger.debug("{}: TV has Powered Off", host); + handler.setOffline(); + break; + case "MajorCh": + case "ChannelName": + case "ProgramTitle": + case "ExternalSource": + case "ID": + case "BrowserURL": + logger.trace("{}: Processing {}:{}", host, el.getNodeName(), el.getTextContent()); + onValueReceived(el.getNodeName(), el.getTextContent(), SERVICE_MAIN_AGENT); + break; } } - - return list; + // visit child node + visitRecursively(node); } @Override public void onStatusChanged(boolean status) { - logger.debug("onStatusChanged: status={}", status); + logger.trace("{}: onStatusChanged: status={}", host, status); + if (!status) { + handler.setOffline(); + } } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MediaRendererService.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MediaRendererService.java old mode 100644 new mode 100755 index 5d337f0c44497..791cff683c3b2 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MediaRendererService.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/MediaRendererService.java @@ -14,17 +14,18 @@ import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.Optional; +import java.util.stream.IntStream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.samsungtv.internal.service.api.EventListener; +import org.openhab.binding.samsungtv.internal.Utils; +import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler; import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; import org.openhab.core.io.transport.upnp.UpnpIOParticipant; import org.openhab.core.io.transport.upnp.UpnpIOService; @@ -33,65 +34,83 @@ import org.openhab.core.library.types.PercentType; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.w3c.dom.Element; +import org.w3c.dom.Node; /** * The {@link MediaRendererService} is responsible for handling MediaRenderer * commands. * * @author Pauli Anttila - Initial contribution + * @author Nick Waterton - added checkConnection(), getServiceName, refactored */ @NonNullByDefault public class MediaRendererService implements UpnpIOParticipant, SamsungTvService { + private final Logger logger = LoggerFactory.getLogger(MediaRendererService.class); public static final String SERVICE_NAME = "MediaRenderer"; - private static final List SUPPORTED_CHANNELS = Arrays.asList(VOLUME, MUTE, BRIGHTNESS, CONTRAST, SHARPNESS, + private static final String SERVICE_RENDERING_CONTROL = "RenderingControl"; + private static final List SUPPORTED_CHANNELS = List.of(VOLUME, MUTE, BRIGHTNESS, CONTRAST, SHARPNESS, COLOR_TEMPERATURE); - - private final Logger logger = LoggerFactory.getLogger(MediaRendererService.class); + protected static final int SUBSCRIPTION_DURATION = 1800; + private static final List ON_VALUE = List.of("true", "1"); private final UpnpIOService service; private final String udn; + private String host = ""; - private Map stateMap = Collections.synchronizedMap(new HashMap<>()); + private final SamsungTvHandler handler; - private Set listeners = new CopyOnWriteArraySet<>(); + private Map stateMap = Collections.synchronizedMap(new HashMap<>()); private boolean started; + private boolean subscription; - public MediaRendererService(UpnpIOService upnpIOService, String udn) { - logger.debug("Creating a Samsung TV MediaRenderer service"); + public MediaRendererService(UpnpIOService upnpIOService, String udn, String host, SamsungTvHandler handler) { this.service = upnpIOService; this.udn = udn; + this.handler = handler; + this.host = host; + logger.debug("{}: Creating a Samsung TV MediaRenderer service: subscription={}", host, getSubscription()); } - @Override - public List getSupportedChannelNames() { - return SUPPORTED_CHANNELS; + private boolean getSubscription() { + return handler.configuration.getSubscription(); } @Override - public void addEventListener(EventListener listener) { - listeners.add(listener); + public String getServiceName() { + return SERVICE_NAME; } @Override - public void removeEventListener(EventListener listener) { - listeners.remove(listener); + public List getSupportedChannelNames(boolean refresh) { + if (refresh) { + if (subscription) { + // Have to do this because old TV's don't update subscriptions properly + if (handler.configuration.isWebsocketProtocol()) { + return List.of(); + } + } + return SUPPORTED_CHANNELS; + } + logger.trace("{}: getSupportedChannelNames: {}", host, SUPPORTED_CHANNELS); + return SUPPORTED_CHANNELS; } @Override public void start() { service.registerParticipant(this); + addSubscription(); started = true; } @Override public void stop() { + removeSubscription(); service.unregisterParticipant(this); started = false; } @@ -107,69 +126,87 @@ public boolean isUpnp() { } @Override - public void handleCommand(String channel, Command command) { - logger.debug("Received channel: {}, command: {}", channel, command); + public boolean checkConnection() { + return started; + } - if (!started) { - return; + @Override + public boolean handleCommand(String channel, Command command) { + logger.trace("{}: Received channel: {}, command: {}", host, channel, command); + boolean result = false; + + if (!checkConnection()) { + return false; } if (command == RefreshType.REFRESH) { if (isRegistered()) { switch (channel) { case VOLUME: - updateResourceState("RenderingControl", "GetVolume", - SamsungTvUtils.buildHashMap("InstanceID", "0", "Channel", "Master")); + updateResourceState("GetVolume"); break; case MUTE: - updateResourceState("RenderingControl", "GetMute", - SamsungTvUtils.buildHashMap("InstanceID", "0", "Channel", "Master")); + updateResourceState("GetMute"); break; case BRIGHTNESS: - updateResourceState("RenderingControl", "GetBrightness", - SamsungTvUtils.buildHashMap("InstanceID", "0")); + updateResourceState("GetBrightness"); break; case CONTRAST: - updateResourceState("RenderingControl", "GetContrast", - SamsungTvUtils.buildHashMap("InstanceID", "0")); + updateResourceState("GetContrast"); break; case SHARPNESS: - updateResourceState("RenderingControl", "GetSharpness", - SamsungTvUtils.buildHashMap("InstanceID", "0")); + updateResourceState("GetSharpness"); break; case COLOR_TEMPERATURE: - updateResourceState("RenderingControl", "GetColorTemperature", - SamsungTvUtils.buildHashMap("InstanceID", "0")); + updateResourceState("GetColorTemperature"); break; default: break; } } - return; + return true; } switch (channel) { case VOLUME: - setVolume(command); + if (command instanceof DecimalType) { + result = sendCommand("SetVolume", cmdToString(command)); + } break; case MUTE: - setMute(command); + if (command instanceof OnOffType) { + result = sendCommand("SetMute", cmdToString(command)); + } break; case BRIGHTNESS: - setBrightness(command); + if (command instanceof DecimalType) { + result = sendCommand("SetBrightness", cmdToString(command)); + } break; case CONTRAST: - setContrast(command); + if (command instanceof DecimalType) { + result = sendCommand("SetContrast", cmdToString(command)); + } break; case SHARPNESS: - setSharpness(command); + if (command instanceof DecimalType) { + result = sendCommand("SetSharpness", cmdToString(command)); + } break; case COLOR_TEMPERATURE: - setColorTemperature(command); + if (command instanceof DecimalType commandAsDecimalType) { + int newValue = Math.max(0, Math.min(commandAsDecimalType.intValue(), 4)); + result = sendCommand("SetColorTemperature", Integer.toString(newValue)); + } break; default: - logger.warn("Samsung TV doesn't support transmitting for channel '{}'", channel); + logger.warn("{}: Samsung TV doesn't support transmitting for channel '{}'", host, channel); + return false; + } + if (!result) { + logger.warn("{}: media renderer: wrong command type {} channel {}", host, command, channel); } + return result; } private boolean isRegistered() { @@ -181,167 +218,149 @@ public String getUDN() { return udn; } + private void addSubscription() { + // Set up GENA Subscriptions + if (isRegistered() && getSubscription()) { + logger.debug("{}: Subscribing to service {}...", host, SERVICE_RENDERING_CONTROL); + service.addSubscription(this, SERVICE_RENDERING_CONTROL, SUBSCRIPTION_DURATION); + } + } + + private void removeSubscription() { + // Remove GENA Subscriptions + if (isRegistered() && subscription) { + logger.debug("{}: Unsubscribing from service {}...", host, SERVICE_RENDERING_CONTROL); + service.removeSubscription(this, SERVICE_RENDERING_CONTROL); + } + } + @Override public void onServiceSubscribed(@Nullable String service, boolean succeeded) { + if (service == null) { + return; + } + subscription = succeeded; + logger.debug("{}: Subscription to service {} {}", host, service, succeeded ? "succeeded" : "failed"); } @Override public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) { - if (variable == null) { + if (variable == null || value == null || service == null || variable.isBlank()) { return; } - String oldValue = stateMap.get(variable); - if ((value == null && oldValue == null) || (value != null && value.equals(oldValue))) { - logger.trace("Value '{}' for {} hasn't changed, ignoring update", value, variable); + variable = variable.replace("Current", ""); + String oldValue = stateMap.getOrDefault(variable, "None"); + if (value.equals(oldValue)) { + logger.trace("{}: Value '{}' for {} hasn't changed, ignoring update", host, value, variable); return; } - stateMap.put(variable, (value != null) ? value : ""); - - for (EventListener listener : listeners) { - switch (variable) { - case "CurrentVolume": - listener.valueReceived(VOLUME, (value != null) ? new PercentType(value) : UnDefType.UNDEF); - break; - - case "CurrentMute": - State newState = UnDefType.UNDEF; - if (value != null) { - newState = OnOffType.from("true".equals(value)); - } - listener.valueReceived(MUTE, newState); - break; - - case "CurrentBrightness": - listener.valueReceived(BRIGHTNESS, (value != null) ? new PercentType(value) : UnDefType.UNDEF); - break; - - case "CurrentContrast": - listener.valueReceived(CONTRAST, (value != null) ? new PercentType(value) : UnDefType.UNDEF); - break; - - case "CurrentSharpness": - listener.valueReceived(SHARPNESS, (value != null) ? new PercentType(value) : UnDefType.UNDEF); - break; - - case "CurrentColorTemperature": - listener.valueReceived(COLOR_TEMPERATURE, - (value != null) ? new DecimalType(value) : UnDefType.UNDEF); - break; - } + stateMap.put(variable, value); + + switch (variable) { + case "LastChange": + stateMap.remove("InstanceID"); + parseEventValues(value); + break; + case "Volume": + handler.valueReceived(VOLUME, new PercentType(value)); + break; + case "Mute": + handler.valueReceived(MUTE, + ON_VALUE.stream().anyMatch(value::equalsIgnoreCase) ? OnOffType.ON : OnOffType.OFF); + break; + case "Brightness": + handler.valueReceived(BRIGHTNESS, new PercentType(value)); + break; + case "Contrast": + handler.valueReceived(CONTRAST, new PercentType(value)); + break; + case "Sharpness": + handler.valueReceived(SHARPNESS, new PercentType(value)); + break; + case "ColorTemperature": + handler.valueReceived(COLOR_TEMPERATURE, new DecimalType(value)); + break; } } - protected Map updateResourceState(String serviceId, String actionId, Map inputs) { - Map result = service.invokeAction(this, serviceId, actionId, inputs); + protected Map updateResourceState(String actionId) { + return updateResourceState(actionId, Map.of()); + } - for (String variable : result.keySet()) { - onValueReceived(variable, result.get(variable), serviceId); + protected synchronized Map updateResourceState(String actionId, Map inputs) { + Map inputsMap = new LinkedHashMap(Map.of("InstanceID", "0")); + if (Utils.isSoundChannel(actionId)) { + inputsMap.put("Channel", "Master"); + } + inputsMap.putAll(inputs); + Map result = service.invokeAction(this, SERVICE_RENDERING_CONTROL, actionId, inputsMap); + if (!subscription) { + result.keySet().stream().forEach(a -> onValueReceived(a, result.get(a), SERVICE_RENDERING_CONTROL)); } - return result; } - private void setVolume(Command command) { - int newValue; - - try { - newValue = DataConverters.convertCommandToIntValue(command, 0, 100, - Integer.valueOf(stateMap.getOrDefault("CurrentVolume", ""))); - } catch (NumberFormatException e) { - throw new NumberFormatException("Command '" + command + "' not supported"); + private boolean sendCommand(String command, String value) { + updateResourceState(command, Map.of(command.replace("Set", "Desired"), value)); + if (!subscription) { + updateResourceState(command.replace("Set", "Get")); } - - updateResourceState("RenderingControl", "SetVolume", SamsungTvUtils.buildHashMap("InstanceID", "0", "Channel", - "Master", "DesiredVolume", Integer.toString(newValue))); - - updateResourceState("RenderingControl", "GetVolume", - SamsungTvUtils.buildHashMap("InstanceID", "0", "Channel", "Master")); + return true; } - private void setMute(Command command) { - boolean newValue; - - try { - newValue = DataConverters.convertCommandToBooleanValue(command); - } catch (NumberFormatException e) { - throw new NumberFormatException("Command '" + command + "' not supported"); + private String cmdToString(Command command) { + if (command instanceof DecimalType commandAsDecimalType) { + return Integer.toString(commandAsDecimalType.intValue()); } - - updateResourceState("RenderingControl", "SetMute", SamsungTvUtils.buildHashMap("InstanceID", "0", "Channel", - "Master", "DesiredMute", Boolean.toString(newValue))); - - updateResourceState("RenderingControl", "GetMute", - SamsungTvUtils.buildHashMap("InstanceID", "0", "Channel", "Master")); - } - - private void setBrightness(Command command) { - int newValue; - - try { - newValue = DataConverters.convertCommandToIntValue(command, 0, 100, - Integer.valueOf(stateMap.getOrDefault("CurrentBrightness", ""))); - } catch (NumberFormatException e) { - throw new NumberFormatException("Command '" + command + "' not supported"); + if (command instanceof OnOffType) { + return Boolean.toString(command.equals(OnOffType.ON)); } - - updateResourceState("RenderingControl", "SetBrightness", - SamsungTvUtils.buildHashMap("InstanceID", "0", "DesiredBrightness", Integer.toString(newValue))); - - updateResourceState("RenderingControl", "GetBrightness", SamsungTvUtils.buildHashMap("InstanceID", "0")); + return command.toString(); } - private void setContrast(Command command) { - int newValue; - - try { - newValue = DataConverters.convertCommandToIntValue(command, 0, 100, - Integer.valueOf(stateMap.getOrDefault("CurrentContrast", ""))); - } catch (NumberFormatException e) { - throw new NumberFormatException("Command '" + command + "' not supported"); - } - - updateResourceState("RenderingControl", "SetContrast", - SamsungTvUtils.buildHashMap("InstanceID", "0", "DesiredContrast", Integer.toString(newValue))); - - updateResourceState("RenderingControl", "GetContrast", SamsungTvUtils.buildHashMap("InstanceID", "0")); + /** + * Parse Subscription Event from {@link String} which contains XML content. + * Parses all child Nodes recursively. + * If valid channel update is found, call onValueReceived() + * + * @param xml{@link String} which contains XML content. + */ + public void parseEventValues(String xml) { + Utils.loadXMLFromString(xml, host).ifPresent(a -> visitRecursively(a)); } - private void setSharpness(Command command) { - int newValue; - - try { - newValue = DataConverters.convertCommandToIntValue(command, 0, 100, - Integer.valueOf(stateMap.getOrDefault("CurrentSharpness", ""))); - } catch (NumberFormatException e) { - throw new NumberFormatException("Command '" + command + "' not supported"); - } - - updateResourceState("RenderingControl", "SetSharpness", - SamsungTvUtils.buildHashMap("InstanceID", "0", "DesiredSharpness", Integer.toString(newValue))); - - updateResourceState("RenderingControl", "GetSharpness", SamsungTvUtils.buildHashMap("InstanceID", "0")); + public void visitRecursively(Node node) { + // get all child nodes, NodeList doesn't have a stream, so do this + Optional.ofNullable(node.getChildNodes()).ifPresent(nList -> IntStream.range(0, nList.getLength()) + .mapToObj(i -> (Node) nList.item(i)).forEach(childNode -> parseNode(childNode))); } - private void setColorTemperature(Command command) { - int newValue; - - try { - newValue = DataConverters.convertCommandToIntValue(command, 0, 4, - Integer.valueOf(stateMap.getOrDefault("CurrentColorTemperature", ""))); - } catch (NumberFormatException e) { - throw new NumberFormatException("Command '" + command + "' not supported"); + public void parseNode(Node node) { + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element el = (Element) node; + if ("InstanceID".equals(el.getNodeName())) { + stateMap.put(el.getNodeName(), el.getAttribute("val")); + } + if (SUPPORTED_CHANNELS.stream().filter(a -> "0".equals(stateMap.get("InstanceID"))) + .anyMatch(el.getNodeName()::equalsIgnoreCase)) { + if (Utils.isSoundChannel(el.getNodeName()) && !"Master".equals(el.getAttribute("channel"))) { + return; + } + logger.trace("{}: Processing {}:{}", host, el.getNodeName(), el.getAttribute("val")); + onValueReceived(el.getNodeName(), el.getAttribute("val"), SERVICE_RENDERING_CONTROL); + } } - - updateResourceState("RenderingControl", "SetColorTemperature", - SamsungTvUtils.buildHashMap("InstanceID", "0", "DesiredColorTemperature", Integer.toString(newValue))); - - updateResourceState("RenderingControl", "GetColorTemperature", SamsungTvUtils.buildHashMap("InstanceID", "0")); + // visit child node + visitRecursively(node); } @Override public void onStatusChanged(boolean status) { - logger.debug("onStatusChanged: status={}", status); + logger.trace("{}: onStatusChanged: status={}", host, status); + if (!status) { + handler.setOffline(); + } } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/RemoteControllerService.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/RemoteControllerService.java old mode 100644 new mode 100755 index 86a362233ce3f..923c93c21c8d0 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/RemoteControllerService.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/RemoteControllerService.java @@ -14,32 +14,27 @@ import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.URI; -import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration; +import org.openhab.binding.samsungtv.internal.Utils; +import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler; import org.openhab.binding.samsungtv.internal.protocol.KeyCode; import org.openhab.binding.samsungtv.internal.protocol.RemoteController; import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerException; import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerLegacy; import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerWebSocket; -import org.openhab.binding.samsungtv.internal.protocol.RemoteControllerWebsocketCallback; -import org.openhab.binding.samsungtv.internal.service.api.EventListener; import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; import org.openhab.core.io.net.http.WebSocketFactory; import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.IncreaseDecreaseType; import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.RawType; import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.UpDownType; import org.openhab.core.thing.ThingStatusDetail; @@ -48,8 +43,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.Gson; - /** * The {@link RemoteControllerService} is responsible for handling remote * controller commands. @@ -57,194 +50,117 @@ * @author Pauli Anttila - Initial contribution * @author Martin van Wingerden - Some changes for manually configured devices * @author Arjan Mels - Implemented websocket interface for recent TVs + * @author Nick Waterton - added power state monitoring for Frame TV's, some refactoring, sendkeys() */ @NonNullByDefault -public class RemoteControllerService implements SamsungTvService, RemoteControllerWebsocketCallback { +public class RemoteControllerService implements SamsungTvService { private final Logger logger = LoggerFactory.getLogger(RemoteControllerService.class); public static final String SERVICE_NAME = "RemoteControlReceiver"; private final List supportedCommandsUpnp = Arrays.asList(KEY_CODE, POWER, CHANNEL); - private final List supportedCommandsNonUpnp = Arrays.asList(KEY_CODE, VOLUME, MUTE, POWER, CHANNEL); - private final List extraSupportedCommandsWebSocket = Arrays.asList(BROWSER_URL, SOURCE_APP, ART_MODE); + private final List supportedCommandsNonUpnp = Arrays.asList(KEY_CODE, VOLUME, MUTE, POWER, CHANNEL, + BROWSER_URL, STOP_BROWSER, SOURCE_APP); + private final List supportedCommandsArt = Arrays.asList(ART_MODE, ART_JSON, ART_LABEL, ART_IMAGE, + ART_BRIGHTNESS, ART_COLOR_TEMPERATURE); + private static final List REFRESH_CHANNELS = Arrays.asList(); + private static final List refreshArt = Arrays.asList(ART_BRIGHTNESS); + private static final List refreshApps = Arrays.asList(SOURCE_APP); + private static final List art2022 = Arrays.asList(ART_MODE, SET_ART_MODE); private String host; - private int port; private boolean upnp; + private String previousApp = "None"; + private final int keyTiming = 300; - boolean power = true; - boolean artMode = false; - - private boolean artModeSupported = false; - - private Set listeners = new CopyOnWriteArraySet<>(); - - private @Nullable RemoteController remoteController = null; - - /** Path for the information endpoint (note the final slash!) */ - private static final String WS_ENDPOINT_V2 = "/api/v2/"; - - /** Description of the json returned for the information endpoint */ - @NonNullByDefault({}) - static class TVProperties { - @NonNullByDefault({}) - static class Device { - boolean FrameTVSupport; - boolean GamePadSupport; - boolean ImeSyncedSupport; - String OS; - boolean TokenAuthSupport; - boolean VoiceSupport; - String countryCode; - String description; - String firmwareVersion; - String modelName; - String name; - String networkType; - String resolution; - } + private long busyUntil = System.currentTimeMillis(); - Device device; - String isSupport; - } + public boolean artMode = false; + public boolean justStarted = true; + /* retry connection count */ + private int retryCount = 0; - /** - * Discover the type of remote control service the TV supports. - * - * @param hostname - * @return map with properties containing at least the protocol and port - */ - public static Map discover(String hostname) { - Map result = new HashMap<>(); + public final SamsungTvHandler handler; - try { - RemoteControllerLegacy remoteController = new RemoteControllerLegacy(hostname, - SamsungTvConfiguration.PORT_DEFAULT_LEGACY, "openHAB", "openHAB"); - remoteController.openConnection(); - remoteController.close(); - result.put(SamsungTvConfiguration.PROTOCOL, SamsungTvConfiguration.PROTOCOL_LEGACY); - result.put(SamsungTvConfiguration.PORT, SamsungTvConfiguration.PORT_DEFAULT_LEGACY); - return result; - } catch (RemoteControllerException e) { - // ignore error - } + private final RemoteController remoteController; - URI uri; + public RemoteControllerService(String host, int port, boolean upnp, SamsungTvHandler handler) + throws RemoteControllerException { + logger.debug("{}: Creating a Samsung TV RemoteController service: is UPNP:{}", host, upnp); + this.upnp = upnp; + this.host = host; + this.handler = handler; try { - uri = new URI("http", null, hostname, SamsungTvConfiguration.PORT_DEFAULT_WEBSOCKET, WS_ENDPOINT_V2, null, - null); - InputStreamReader reader = new InputStreamReader(uri.toURL().openStream()); - TVProperties properties = new Gson().fromJson(reader, TVProperties.class); - - if (properties.device.TokenAuthSupport) { - result.put(SamsungTvConfiguration.PROTOCOL, SamsungTvConfiguration.PROTOCOL_SECUREWEBSOCKET); - result.put(SamsungTvConfiguration.PORT, SamsungTvConfiguration.PORT_DEFAULT_SECUREWEBSOCKET); + if (upnp) { + remoteController = new RemoteControllerLegacy(host, port, "openHAB", "openHAB"); + remoteController.openConnection(); } else { - result.put(SamsungTvConfiguration.PROTOCOL, SamsungTvConfiguration.PROTOCOL_WEBSOCKET); - result.put(SamsungTvConfiguration.PORT, SamsungTvConfiguration.PORT_DEFAULT_WEBSOCKET); + remoteController = new RemoteControllerWebSocket(host, port, "openHAB", "openHAB", this); } - } catch (URISyntaxException | IOException e) { - LoggerFactory.getLogger(RemoteControllerService.class).debug("Cannot retrieve info from TV", e); - result.put(SamsungTvConfiguration.PROTOCOL, SamsungTvConfiguration.PROTOCOL_NONE); + } catch (RemoteControllerException e) { + throw new RemoteControllerException("Cannot create RemoteControllerService", e); } - - return result; } - private RemoteControllerService(String host, int port, boolean upnp) { - logger.debug("Creating a Samsung TV RemoteController service: {}", upnp); - this.upnp = upnp; - this.host = host; - this.port = port; - } - - static RemoteControllerService createUpnpService(String host, int port) { - return new RemoteControllerService(host, port, true); - } - - public static RemoteControllerService createNonUpnpService(String host, int port) { - return new RemoteControllerService(host, port, false); + @Override + public String getServiceName() { + return SERVICE_NAME; } @Override - public List getSupportedChannelNames() { - List supported = upnp ? supportedCommandsUpnp : supportedCommandsNonUpnp; - if (remoteController instanceof RemoteControllerWebSocket) { - supported = new ArrayList<>(supported); - supported.addAll(extraSupportedCommandsWebSocket); + public List getSupportedChannelNames(boolean refresh) { + // no refresh channels for UPNP remotecontroller + List supported = new ArrayList<>(refresh ? upnp ? Arrays.asList() : REFRESH_CHANNELS + : upnp ? supportedCommandsUpnp : supportedCommandsNonUpnp); + if (getArtModeSupported()) { + supported.addAll(refresh ? refreshArt : supportedCommandsArt); + } + if (getArtMode2022()) { + supported.addAll(refresh ? Arrays.asList() : art2022); + } + if (remoteController.noApps() && getPowerState() && refresh) { + supported.addAll(refreshApps); + } + if (!refresh) { + logger.trace("{}: getSupportedChannelNames: {}", host, supported); } - logger.trace("getSupportedChannelNames: {}", supported); return supported; } @Override - public void addEventListener(EventListener listener) { - listeners.add(listener); - } - - @Override - public void removeEventListener(EventListener listener) { - listeners.remove(listener); - } - public boolean checkConnection() { - if (remoteController != null) { - return remoteController.isConnected(); - } else { - return false; - } + return remoteController.isConnected(); } @Override public void start() { - if (remoteController != null) { - try { - remoteController.openConnection(); - } catch (RemoteControllerException e) { - logger.warn("Cannot open remote interface ({})", e.getMessage()); - } - return; - } - - String protocol = (String) getConfig(SamsungTvConfiguration.PROTOCOL); - logger.info("Using {} interface", protocol); - - if (SamsungTvConfiguration.PROTOCOL_LEGACY.equals(protocol)) { - remoteController = new RemoteControllerLegacy(host, port, "openHAB", "openHAB"); - } else if (SamsungTvConfiguration.PROTOCOL_WEBSOCKET.equals(protocol) - || SamsungTvConfiguration.PROTOCOL_SECUREWEBSOCKET.equals(protocol)) { - try { - remoteController = new RemoteControllerWebSocket(host, port, "openHAB", "openHAB", this); - } catch (RemoteControllerException e) { - reportError("Cannot connect to remote control service", e); - } - } else { - remoteController = null; - return; - } - - if (remoteController != null) { - try { + try { + if (!checkConnection()) { remoteController.openConnection(); - } catch (RemoteControllerException e) { - reportError("Cannot connect to remote control service", e); } + } catch (RemoteControllerException e) { + reportError("Cannot connect to remote control service", e); } + previousApp = ""; } @Override public void stop() { - if (remoteController != null) { - try { - remoteController.close(); - } catch (RemoteControllerException ignore) { - } + try { + remoteController.close(); + } catch (RemoteControllerException ignore) { + // ignore error } } + /** + * Clears the UPnP cache, or reconnects a websocket if diconnected + * Here we reconnect the websocket + */ @Override public void clearCache() { + start(); } @Override @@ -253,192 +169,322 @@ public boolean isUpnp() { } @Override - public void handleCommand(String channel, Command command) { - logger.trace("Received channel: {}, command: {}", channel, command); - if (command == RefreshType.REFRESH) { - return; + public boolean handleCommand(String channel, Command command) { + logger.trace("{}: Received channel: {}, command: {}", host, channel, Utils.truncCmd(command)); + + boolean result = false; + if (!checkConnection() && !SET_ART_MODE.equals(channel)) { + logger.debug("{}: RemoteController is not connected", host); + if (getArtMode2022() && retryCount < 4) { + retryCount += 1; + logger.debug("{}: Reconnecting RemoteController, retry: {}", host, retryCount); + start(); + return handler.handleCommand(channel, command, 3000); + } else { + logger.warn("{}: TV is not responding - not reconnecting", host); + } + return false; } + retryCount = 0; - if (remoteController == null) { - return; + if (command == RefreshType.REFRESH) { + switch (channel) { + case SOURCE_APP: + remoteController.updateCurrentApp(); + break; + case ART_IMAGE: + case ART_LABEL: + remoteController.getArtmodeStatus("get_current_artwork"); + break; + case ART_BRIGHTNESS: + remoteController.getArtmodeStatus("get_brightness"); + break; + case ART_COLOR_TEMPERATURE: + remoteController.getArtmodeStatus("get_color_temperature"); + break; + } + return true; } - KeyCode key = null; + switch (channel) { + case BROWSER_URL: + if (command instanceof StringType) { + remoteController.sendUrl(command.toString()); + result = true; + } + break; - if (remoteController instanceof RemoteControllerWebSocket remoteControllerWebSocket) { - switch (channel) { - case BROWSER_URL: - if (command instanceof StringType) { - remoteControllerWebSocket.sendUrl(command.toString()); + case STOP_BROWSER: + if (command instanceof OnOffType) { + if (command.equals(OnOffType.ON)) { + return handleCommand(SOURCE_APP, new StringType("")); } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + sendKeys(KeyCode.KEY_EXIT, 2000); } - return; - case SOURCE_APP: - if (command instanceof StringType) { - remoteControllerWebSocket.sendSourceApp(command.toString()); + result = true; + } + break; + + case SOURCE_APP: + if (command instanceof StringType) { + remoteController.sendSourceApp(command.toString()); + result = true; + } + break; + + case POWER: + if (command instanceof OnOffType) { + if (!isUpnp()) { + // websocket uses KEY_POWER + if (OnOffType.ON.equals(command) != getPowerState()) { + // send key only to toggle state + sendKeys(KeyCode.KEY_POWER); + if (getArtMode2022()) { + if (!getPowerState() & !artMode) { + // second key press to get out of art mode, once tv online + List commands = new ArrayList<>(); + commands.add(9000); + commands.add(KeyCode.KEY_POWER); + sendKeys(commands); + updateArtMode(OnOffType.OFF.equals(command), 9000); + } else { + updateArtMode(OnOffType.OFF.equals(command), 1000); + } + } + } } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + // legacy controller uses KEY_POWERON/OFF + if (command.equals(OnOffType.ON)) { + sendKeys(KeyCode.KEY_POWERON); + } else { + sendKeys(KeyCode.KEY_POWEROFF); + } } - return; - case POWER: - if (command instanceof OnOffType) { - // websocket uses KEY_POWER - // send key only to toggle state - if (OnOffType.ON.equals(command) != power) { - sendKeyCode(KeyCode.KEY_POWER); + result = true; + } + break; + + case SET_ART_MODE: + // Used to manually set art mode for >=2022 Frame TV's + logger.trace("{}: Setting Artmode to: {} artmode is: {}", host, command, artMode); + if (command instanceof OnOffType) { + handler.valueReceived(SET_ART_MODE, OnOffType.from(OnOffType.ON.equals(command))); + if (OnOffType.ON.equals(command) != artMode || justStarted) { + justStarted = false; + updateArtMode(OnOffType.ON.equals(command)); + } + result = true; + } + break; + + case ART_MODE: + if (command instanceof OnOffType) { + // websocket uses KEY_POWER + // send key only to toggle state when power = off + if (!getPowerState()) { + if (OnOffType.ON.equals(command)) { + if (!artMode) { + sendKeys(KeyCode.KEY_POWER); + } + } else if (artMode) { + // really switch off (long press of power) + sendKeys(KeyCode.KEY_POWER, 4000); } } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + // switch TV off + sendKeys(KeyCode.KEY_POWER); } - return; - case ART_MODE: - if (command instanceof OnOffType) { - // websocket uses KEY_POWER - // send key only to toggle state when power = off - if (!power) { - if (OnOffType.ON.equals(command)) { - if (!artMode) { - sendKeyCode(KeyCode.KEY_POWER); - } + if (getArtMode2022()) { + if (OnOffType.ON.equals(command)) { + if (!getPowerState()) { + // wait for TV to come online + updateArtMode(true, 3000); } else { - sendKeyCodePress(KeyCode.KEY_POWER); - // really switch off + updateArtMode(true, 1000); } } else { - // switch TV off - sendKeyCode(KeyCode.KEY_POWER); - // switch TV to art mode - sendKeyCode(KeyCode.KEY_POWER); + this.artMode = false; } - } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); } - return; - } - } + result = true; + } + break; + + case ART_JSON: + if (command instanceof StringType) { + String artJson = command.toString(); + if (!artJson.contains("\"id\"")) { + artJson = artJson.replaceFirst("}$", ",}"); + } + remoteController.getArtmodeStatus(artJson); + result = true; + } + break; + + case ART_IMAGE: + case ART_LABEL: + if (command instanceof RawType) { + remoteController.getArtmodeStatus("send_image", command.toFullString()); + } else if (command instanceof StringType) { + if (command.toString().startsWith("data:image")) { + remoteController.getArtmodeStatus("send_image", command.toString()); + } else if (channel.equals(ART_LABEL)) { + remoteController.getArtmodeStatus("select_image", command.toString()); + } + result = true; + } + break; + + case ART_BRIGHTNESS: + if (command instanceof DecimalType decimalCommand) { + int value = decimalCommand.intValue(); + remoteController.getArtmodeStatus("set_brightness", String.valueOf(value / 10)); + result = true; + } + break; + + case ART_COLOR_TEMPERATURE: + if (command instanceof DecimalType decimalCommand) { + int value = Math.max(-5, Math.min(decimalCommand.intValue(), 5)); + remoteController.getArtmodeStatus("set_color_temperature", String.valueOf(value)); + result = true; + } + break; - switch (channel) { case KEY_CODE: if (command instanceof StringType) { - try { - key = KeyCode.valueOf(command.toString().toUpperCase()); - } catch (IllegalArgumentException e) { + // split on [, +], but not if encloded in "" or {} + String[] cmds = command.toString().strip().split("(?=(?:(?:[^\"]*\"){2})*[^\"]*$)(?![^{]*})[, +]+", + 0); + List commands = new ArrayList<>(); + for (String cmd : cmds) { try { - key = KeyCode.valueOf("KEY_" + command.toString().toUpperCase()); - } catch (IllegalArgumentException e2) { - // do nothing, error message is logged later + logger.trace("{}: Procesing command: {}", host, cmd); + if (cmd.startsWith("\"") || cmd.startsWith("{")) { + // remove leading and trailing " + cmd = cmd.replaceAll("^\"|\"$", ""); + commands.add(cmd); + if (!cmd.startsWith("{")) { + commands.add(""); + } + } else if (cmd.matches("-?\\d{2,5}")) { + commands.add(Integer.parseInt(cmd)); + } else { + String ucmd = cmd.toUpperCase(); + commands.add(KeyCode.valueOf(ucmd.startsWith("KEY_") ? ucmd : "KEY_" + ucmd)); + } + } catch (IllegalArgumentException e) { + logger.warn("{}: Remote control: unsupported cmd {} channel {}, {}", host, cmd, channel, + e.getMessage()); + return false; } } - - if (key != null) { - sendKeyCode(key); - } else { - logger.warn("Remote control: Command '{}' not supported for channel '{}'", command, channel); + if (!commands.isEmpty()) { + sendKeys(commands); } - } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + result = true; } - return; + break; - case POWER: + case MUTE: if (command instanceof OnOffType) { - // legacy controller uses KEY_POWERON/OFF - if (command.equals(OnOffType.ON)) { - sendKeyCode(KeyCode.KEY_POWERON); - } else { - sendKeyCode(KeyCode.KEY_POWEROFF); - } - } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + sendKeys(KeyCode.KEY_MUTE); + result = true; } - return; - - case MUTE: - sendKeyCode(KeyCode.KEY_MUTE); - return; + break; case VOLUME: - if (command instanceof UpDownType) { - if (command.equals(UpDownType.UP)) { - sendKeyCode(KeyCode.KEY_VOLUP); + if (command instanceof UpDownType || command instanceof IncreaseDecreaseType) { + if (command.equals(UpDownType.UP) || command.equals(IncreaseDecreaseType.INCREASE)) { + sendKeys(KeyCode.KEY_VOLUP); } else { - sendKeyCode(KeyCode.KEY_VOLDOWN); + sendKeys(KeyCode.KEY_VOLDOWN); } - } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + result = true; } - return; + break; case CHANNEL: if (command instanceof DecimalType decimalCommand) { - int val = decimalCommand.intValue(); - int num4 = val / 1000 % 10; - int num3 = val / 100 % 10; - int num2 = val / 10 % 10; - int num1 = val % 10; - - List commands = new ArrayList<>(); - - if (num4 > 0) { - commands.add(KeyCode.valueOf("KEY_" + num4)); - } - if (num4 > 0 || num3 > 0) { - commands.add(KeyCode.valueOf("KEY_" + num3)); - } - if (num4 > 0 || num3 > 0 || num2 > 0) { - commands.add(KeyCode.valueOf("KEY_" + num2)); - } - commands.add(KeyCode.valueOf("KEY_" + num1)); + KeyCode[] codes = String.valueOf(decimalCommand.intValue()).chars() + .mapToObj(c -> KeyCode.valueOf("KEY_" + String.valueOf((char) c))).toArray(KeyCode[]::new); + List commands = new ArrayList<>(Arrays.asList(codes)); commands.add(KeyCode.KEY_ENTER); - sendKeyCodes(commands); - } else { - logger.warn("Remote control: unsupported command type {} for channel {}", command, channel); + sendKeys(commands); + result = true; } - return; + break; default: - logger.warn("Remote control: unsupported channel: {}", channel); + logger.warn("{}: Remote control: unsupported channel: {}", host, channel); + return false; } + if (!result) { + logger.warn("{}: Remote control: wrong command type {} channel {}", host, command, channel); + } + return result; } - /** - * Sends a command to Samsung TV device. - * - * @param key Button code to send - */ - private void sendKeyCode(KeyCode key) { - try { - if (remoteController != null) { - remoteController.sendKey(key); - } - } catch (RemoteControllerException e) { - reportError(String.format("Could not send command to device on %s:%d", host, port), e); - } + public synchronized void sendKeys(KeyCode key, int press) { + sendKeys(Arrays.asList(key), press); } - private void sendKeyCodePress(KeyCode key) { - try { - if (remoteController instanceof RemoteControllerWebSocket remoteControllerWebSocket) { - remoteControllerWebSocket.sendKeyPress(key); - } - } catch (RemoteControllerException e) { - reportError(String.format("Could not send command to device on %s:%d", host, port), e); - } + public synchronized void sendKeys(KeyCode key) { + sendKeys(Arrays.asList(key), 0); + } + + public synchronized void sendKeys(List keys) { + sendKeys(keys, 0); } /** - * Sends a sequence of command to Samsung TV device. + * Send sequence of key codes to Samsung TV RemoteController instance. + * 300 ms between each key click. If press is > 0 then send key press/release * - * @param keys List of button codes to send + * @param keys List containing key codes/Integer delays to send. + * if integer delays are negative, send key press of abs(delay) + * @param press int value of length of keypress in ms (0 means Click) */ - private void sendKeyCodes(final List keys) { - try { - if (remoteController != null) { - remoteController.sendKeys(keys); + public synchronized void sendKeys(List keys, int press) { + int timingInMs = keyTiming; + int delay = (int) Math.max(0, busyUntil - System.currentTimeMillis()); + @Nullable + ScheduledExecutorService scheduler = getScheduler(); + if (scheduler == null) { + logger.warn("{}: Unable to schedule key sequence", host); + return; + } + for (int i = 0; i < keys.size(); i++) { + Object key = keys.get(i); + if (key instanceof Integer keyAsInt) { + if (keyAsInt > 0) { + delay += Math.max(0, keyAsInt - (2 * timingInMs)); + } else { + press = Math.max(timingInMs, Math.abs(keyAsInt)); + delay -= timingInMs; + } + continue; } - } catch (RemoteControllerException e) { - reportError(String.format("Could not send command to device on %s:%d", host, port), e); + if (press == 0 && key instanceof KeyCode && key.equals(KeyCode.KEY_BT_VOICE)) { + press = 3000; + delay -= timingInMs; + } + int duration = press; + scheduler.schedule(() -> { + if (duration > 0) { + remoteController.sendKeyPress((KeyCode) key, duration); + } else { + if (key instanceof String keyAsString) { + remoteController.sendKey(keyAsString); + } else { + remoteController.sendKey((KeyCode) key); + } + } + }, (i * timingInMs) + delay, TimeUnit.MILLISECONDS); + delay += press; + press = 0; } + busyUntil = System.currentTimeMillis() + (keys.size() * timingInMs) + delay; + logger.trace("{}: Key Sequence Queued", host); } private void reportError(String message, RemoteControllerException e) { @@ -446,71 +492,123 @@ private void reportError(String message, RemoteControllerException e) { } private void reportError(ThingStatusDetail statusDetail, String message, RemoteControllerException e) { - for (EventListener listener : listeners) { - listener.reportError(statusDetail, message, e); - } + handler.reportError(statusDetail, message, e); } - @Override public void appsUpdated(List apps) { // do nothing } - @Override - public void currentAppUpdated(@Nullable String app) { - for (EventListener listener : listeners) { - listener.valueReceived(SOURCE_APP, new StringType(app)); - } + public void updateCurrentApp() { + remoteController.updateCurrentApp(); } - @Override - public void powerUpdated(boolean on, boolean artmode) { - artModeSupported = true; - power = on; - this.artMode = artmode; - - for (EventListener listener : listeners) { - // order of state updates is important to prevent extraneous transitions in overall state - if (on) { - listener.valueReceived(POWER, OnOffType.from(on)); - listener.valueReceived(ART_MODE, OnOffType.from(artmode)); - } else { - listener.valueReceived(ART_MODE, OnOffType.from(artmode)); - listener.valueReceived(POWER, OnOffType.from(on)); - } + public synchronized void currentAppUpdated(String app) { + if (!previousApp.equals(app)) { + handler.valueReceived(SOURCE_APP, new StringType(app)); + previousApp = app; } } - @Override - public void connectionError(@Nullable Throwable error) { - logger.debug("Connection error: {}", error != null ? error.getMessage() : ""); - remoteController = null; + public void updateArtMode(boolean artMode, int ms) { + @Nullable + ScheduledExecutorService scheduler = getScheduler(); + if (scheduler == null) { + logger.warn("{}: Unable to schedule art mode update", host); + } else { + scheduler.schedule(() -> { + updateArtMode(artMode); + }, ms, TimeUnit.MILLISECONDS); + } } - public boolean isArtModeSupported() { - return artModeSupported; + public synchronized void updateArtMode(boolean artMode) { + // manual update of power/art mode for >=2022 frame TV's + if (this.artMode == artMode) { + logger.debug("{}: Artmode setting is already: {}", host, artMode); + return; + } + if (artMode) { + logger.debug("{}: Setting power state OFF, Art Mode ON", host); + powerUpdated(false, true); + } else { + logger.debug("{}: Setting power state ON, Art Mode OFF", host); + powerUpdated(true, false); + } + if (this.artMode) { + currentAppUpdated("artMode"); + } else { + currentAppUpdated(""); + } + handler.valueReceived(SET_ART_MODE, OnOffType.from(this.artMode)); + if (!remoteController.noApps()) { + updateCurrentApp(); + } } - @Override - public void putConfig(String key, Object value) { - for (EventListener listener : listeners) { - listener.putConfig(key, value); + public void powerUpdated(boolean on, boolean artMode) { + String powerState = fetchPowerState(); + if (!getArtMode2022()) { + setArtModeSupported(true); + } + if (!"on".equals(powerState)) { + on = false; + artMode = false; + currentAppUpdated(""); + } + setPowerState(on); + this.artMode = artMode; + // order of state updates is important to prevent extraneous transitions in overall state + if (on) { + handler.valueReceived(POWER, OnOffType.from(on)); + handler.valueReceived(ART_MODE, OnOffType.from(artMode)); + } else { + handler.valueReceived(ART_MODE, OnOffType.from(artMode)); + handler.valueReceived(POWER, OnOffType.from(on)); } } - @Override - public @Nullable Object getConfig(String key) { - for (EventListener listener : listeners) { - return listener.getConfig(key); - } - return null; + public boolean getArtMode2022() { + return handler.getArtMode2022(); + } + + public void setArtMode2022(boolean artmode) { + handler.setArtMode2022(artmode); + } + + public boolean getArtModeSupported() { + return handler.getArtModeSupported(); + } + + public void setArtModeSupported(boolean artmode) { + handler.setArtModeSupported(artmode); + } + + public boolean getPowerState() { + return handler.getPowerState(); + } + + public void setPowerState(boolean power) { + handler.setPowerState(power); + } + + public String fetchPowerState() { + return handler.fetchPowerState(); + } + + public void setOffline() { + handler.setOffline(); + } + + public void putConfig(String key, String value) { + handler.putConfig(key, value); + } + + public @Nullable ScheduledExecutorService getScheduler() { + return handler.getScheduler(); } - @Override public @Nullable WebSocketFactory getWebSocketFactory() { - for (EventListener listener : listeners) { - return listener.getWebSocketFactory(); - } - return null; + return handler.getWebSocketFactory(); } } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/SamsungTvUtils.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/SamsungTvUtils.java deleted file mode 100644 index 4690882522428..0000000000000 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/SamsungTvUtils.java +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.samsungtv.internal.service; - -import java.io.IOException; -import java.io.StringReader; -import java.util.HashMap; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.w3c.dom.Document; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; - -/** - * The {@link SamsungTvUtils} provides some utilities for internal use. - * - * @author Pauli Anttila - Initial contribution - */ -@NonNullByDefault -public class SamsungTvUtils { - - /** - * Build {@link String} type {@link HashMap} from variable number of - * {@link String}s. - * - * @param data - * Variable number of {@link String} parameters which will be - * added to hash map. - */ - public static HashMap buildHashMap(String... data) { - HashMap result = new HashMap<>(); - - if (data.length % 2 != 0) { - throw new IllegalArgumentException("Odd number of arguments"); - } - String key = null; - Integer step = -1; - - for (String value : data) { - step++; - switch (step % 2) { - case 0: - if (value == null) { - throw new IllegalArgumentException("Null key value"); - } - key = value; - continue; - case 1: - if (key != null) { - result.put(key, value); - } - break; - } - } - - return result; - } - - /** - * Build {@link Document} from {@link String} which contains XML content. - * - * @param xml - * {@link String} which contains XML content. - * @return {@link Document} or null if convert has failed. - */ - public static @Nullable Document loadXMLFromString(String xml) { - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - DocumentBuilder builder = factory.newDocumentBuilder(); - InputSource is = new InputSource(new StringReader(xml)); - return builder.parse(is); - - } catch (ParserConfigurationException | SAXException | IOException e) { - // Silently ignore exception and return null. - } - - return null; - } -} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/ServiceFactory.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/ServiceFactory.java deleted file mode 100644 index 6dcbb30a5e6ca..0000000000000 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/ServiceFactory.java +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.samsungtv.internal.service; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; -import org.openhab.core.io.transport.upnp.UpnpIOService; - -/** - * The {@link ServiceFactory} is helper class for creating Samsung TV related - * services. - * - * @author Pauli Anttila - Initial contribution - */ -@NonNullByDefault -public class ServiceFactory { - - @SuppressWarnings("serial") - private static final Map> SERVICEMAP = Collections - .unmodifiableMap(new HashMap<>() { - { - put(MainTVServerService.SERVICE_NAME, MainTVServerService.class); - put(MediaRendererService.SERVICE_NAME, MediaRendererService.class); - put(RemoteControllerService.SERVICE_NAME, RemoteControllerService.class); - } - }); - - /** - * Create Samsung TV service. - * - * @param type - * @param upnpIOService - * @param udn - * @param host - * @param port - * @return - */ - public static @Nullable SamsungTvService createService(String type, UpnpIOService upnpIOService, String udn, - String host, int port) { - SamsungTvService service = null; - - switch (type) { - case MainTVServerService.SERVICE_NAME: - service = new MainTVServerService(upnpIOService, udn); - break; - case MediaRendererService.SERVICE_NAME: - service = new MediaRendererService(upnpIOService, udn); - break; - // will not be created automatically - case RemoteControllerService.SERVICE_NAME: - service = RemoteControllerService.createUpnpService(host, port); - break; - - } - - return service; - } - - /** - * Procedure to query amount of supported services. - * - * @return Amount of supported services - */ - public static int getServiceCount() { - return SERVICEMAP.size(); - } - - /** - * Procedure to get service class by service name. - * - * @param serviceName Name of the service - * @return Class of the service - */ - public static @Nullable Class getClassByServiceName(String serviceName) { - return SERVICEMAP.get(serviceName); - } -} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/SmartThingsApiService.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/SmartThingsApiService.java new file mode 100755 index 0000000000000..68c125a92283c --- /dev/null +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/SmartThingsApiService.java @@ -0,0 +1,976 @@ +/** + * Copyright (c) 2010-2024 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.samsungtv.internal.service; + +import static org.openhab.binding.samsungtv.internal.SamsungTvBindingConstants.*; +import static org.openhab.binding.samsungtv.internal.config.SamsungTvConfiguration.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodySubscriber; +import java.net.http.HttpResponse.ResponseInfo; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Flow.Subscription; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpMethod; +import org.openhab.binding.samsungtv.internal.handler.SamsungTvHandler; +import org.openhab.binding.samsungtv.internal.service.api.SamsungTvService; +import org.openhab.core.io.net.http.HttpUtil; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.google.gson.annotations.SerializedName; + +/** + * The {@link SmartThingsApiService} is responsible for handling the Smartthings cloud interface + * + * + * @author Nick Waterton - Initial contribution + */ +@NonNullByDefault +public class SmartThingsApiService implements SamsungTvService { + + public static final String SERVICE_NAME = "SmartthingsApi"; + private static final List SUPPORTED_CHANNELS = Arrays.asList(SOURCE_NAME, SOURCE_ID); + private static final List REFRESH_CHANNELS = Arrays.asList(CHANNEL, CHANNEL_NAME, SOURCE_NAME, SOURCE_ID); + // Smarttings URL + private static final String SMARTTHINGS_URL = "api.smartthings.com"; + // Path for the information endpoint note the final / + private static final String API_ENDPOINT_V1 = "/v1/"; + // private static final String INPUT_SOURCE = "/components/main/capabilities/mediaInputSource/status"; + // private static final String CURRENT_CHANNEL = "/components/main/capabilities/tvChannel/status"; + private static final String COMPONENTS = "/components/main/status"; + private static final String DEVICES = "devices"; + private static final String COMMAND = "/commands"; + + private final Logger logger = LoggerFactory.getLogger(SmartThingsApiService.class); + + private String host = ""; + private String apiKey = ""; + private String deviceId = ""; + private int RATE_LIMIT = 1000; + private int TIMEOUT = 1000; // connection timeout in ms + private long prevUpdate = 0; + private boolean online = false; + private int errorCount = 0; + private int MAX_ERRORS = 100; + + private final SamsungTvHandler handler; + + private Optional tvInfo = Optional.empty(); + private boolean subscriptionRunning = false; + private Optional handlerWrapper = Optional.empty(); + private Optional subscription = Optional.empty(); + + private Map stateMap = Collections.synchronizedMap(new HashMap<>()); + + public SmartThingsApiService(String host, SamsungTvHandler handler) { + this.handler = handler; + this.host = host; + this.apiKey = handler.configuration.getSmartThingsApiKey(); + this.deviceId = handler.configuration.getSmartThingsDeviceId(); + logger.debug("{}: Creating a Samsung TV Smartthings Api service", host); + } + + @Override + public String getServiceName() { + return SERVICE_NAME; + } + + @Override + public List getSupportedChannelNames(boolean refresh) { + if (refresh) { + if (subscriptionRunning) { + return Arrays.asList(); + } + return REFRESH_CHANNELS; + } + logger.trace("{}: getSupportedChannelNames: {}", host, SUPPORTED_CHANNELS); + return SUPPORTED_CHANNELS; + } + + // Description of tvValues + @NonNullByDefault({}) + class TvValues { + class MediaInputSource { + ValuesList supportedInputSources; + ValuesListMap supportedInputSourcesMap; + Values inputSource; + } + + class TvChannel { + Values tvChannel; + Values tvChannelName; + } + + class Values { + String value; + String timestamp; + } + + class ValuesList { + String[] value; + String timestamp; + } + + class ValuesListMap { + InputList[] value; + String timestamp; + + public String[] getInputList() { + return Optional.ofNullable(value).map(a -> Arrays.stream(a).map(b -> b.getId()).toArray(String[]::new)) + .orElse(new String[0]); + } + } + + class InputList { + public String id; + String name; + + public String getId() { + return Optional.ofNullable(id).orElse(""); + } + } + + class Items { + String deviceId; + String name; + String label; + + public String getDeviceId() { + return Optional.ofNullable(deviceId).orElse(""); + } + + public String getName() { + return Optional.ofNullable(name).orElse(""); + } + + public String getLabel() { + return Optional.ofNullable(label).orElse(""); + } + } + + class Error { + String code; + String message; + Details[] details; + } + + class Details { + String code; + String target; + String message; + } + + @SerializedName(value = "samsungvd.mediaInputSource", alternate = { "mediaInputSource" }) + MediaInputSource mediaInputSource; + TvChannel tvChannel; + Items[] items; + Error error; + + public void updateSupportedInputSources(String[] values) { + mediaInputSource.supportedInputSources.value = values; + } + + public Items[] getItems() { + return Optional.ofNullable(items).orElse(new Items[0]); + } + + public String[] getSources() { + return Optional.ofNullable(mediaInputSource).map(a -> a.supportedInputSources).map(a -> a.value) + .orElseGet(() -> getSourcesFromMap()); + } + + public String[] getSourcesFromMap() { + return Optional.ofNullable(mediaInputSource).map(a -> a.supportedInputSourcesMap).map(a -> a.getInputList()) + .orElse(new String[0]); + } + + public String getSourcesString() { + return Arrays.asList(getSources()).stream().collect(Collectors.joining(",")); + } + + public String getInputSource() { + return Optional.ofNullable(mediaInputSource).map(a -> a.inputSource).map(a -> a.value).orElse(""); + } + + public int getInputSourceId() { + return IntStream.range(0, getSources().length).filter(i -> getSources()[i].equals(getInputSource())) + .findFirst().orElse(-1); + } + + public Number getTvChannel() { + return Optional.ofNullable(tvChannel).map(a -> a.tvChannel).map(a -> a.value).filter(i -> !i.isBlank()) + .map(j -> parseTVChannel(j)).orElse(-1f); + } + + public String getTvChannelName() { + return Optional.ofNullable(tvChannel).map(a -> a.tvChannelName).map(a -> a.value).orElse(""); + } + + public boolean isError() { + return Optional.ofNullable(error).isPresent(); + } + + public String getError() { + String code = Optional.ofNullable(error).map(a -> a.code).orElse(""); + String message = Optional.ofNullable(error).map(a -> a.message).orElse(""); + return String.format("%s, %s", code, message); + } + } + + @NonNullByDefault({}) + class JSONContent { + public JSONContent(String capability, String action, String value) { + Command command = new Command(); + command.capability = capability; + command.command = action; + command.arguments = new String[] { value }; + commands = new Command[] { command }; + } + + class Command { + String component = "main"; + String capability; + String command; + String[] arguments; + } + + Command[] commands; + } + + @NonNullByDefault({}) + class JSONSubscriptionFilter { + public JSONSubscriptionFilter(String deviceId) { + SubscriptionFilter sub = new SubscriptionFilter(); + sub.value = new String[] { deviceId }; + subscriptionFilters = new SubscriptionFilter[] { sub }; + } + + class SubscriptionFilter { + String type = "DEVICEIDS"; + String[] value; + } + + SubscriptionFilter[] subscriptionFilters; + String name = "OpenHAB Subscription"; + } + + @NonNullByDefault({}) + class STSubscription { + + String subscriptionId; + String registrationUrl; + String name; + Integer version; + SubscriptionFilters[] subscriptionFilters; + + class SubscriptionFilters { + String type; + String[] value; + } + + public String getSubscriptionId() { + return Optional.ofNullable(subscriptionId).orElse(""); + } + + public String getregistrationUrl() { + return Optional.ofNullable(registrationUrl).orElse(""); + } + } + + @NonNullByDefault({}) + class STSSEData { + + long eventTime; + String eventType; + DeviceEvent deviceEvent; + Optional tvInfo = Optional.empty(); + + class DeviceEvent { + + String eventId; + String locationId; + String ownerId; + String ownerType; + String deviceId; + String componentId; + String capability; // example "sec.diagnosticsInformation" + String attribute; // example "dumpType" + JsonElement value; // example "id" or can be an array + String valueType; + boolean stateChange; + JsonElement data; + String subscriptionName; + + class ValuesList { + // Array of supportedInputSourcesMap + String id; + String name; + + public String getId() { + return Optional.ofNullable(id).orElse(""); + } + + public String getName() { + return Optional.ofNullable(name).orElse(""); + } + + @Override + public String toString() { + return Map.of("id", getId(), "name", getName()).toString(); + } + } + + public String getCapability() { + return Optional.ofNullable(capability).orElse(""); + } + + public String getAttribute() { + return Optional.ofNullable(attribute).orElse(""); + } + + public String getValueType() { + return Optional.ofNullable(valueType).orElse(""); + } + + public List getValuesAsList() throws JsonSyntaxException { + if ("array".equals(getValueType())) { + JsonArray resultArray = Optional.ofNullable((JsonArray) value.getAsJsonArray()) + .orElse(new JsonArray()); + try { + if (resultArray.get(0) instanceof JsonObject) { + // Only for Array of supportedInputSourcesMap + ValuesList[] values = new Gson().fromJson(resultArray, ValuesList[].class); + List result = Optional.ofNullable(values).map(a -> Arrays.asList(a)) + .orElse(new ArrayList()); + return Optional.ofNullable(result).orElse(List.of()); + } else { + List result = new Gson().fromJson(resultArray, ArrayList.class); + return Optional.ofNullable(result).orElse(List.of()); + } + } catch (IllegalStateException e) { + } + } + return List.of(); + } + + public String getValue() { + if ("string".equals(getValueType())) { + return Optional.ofNullable((String) value.getAsString()).orElse(""); + } + return ""; + } + } + + public void setTvInfo(Optional tvInfo) { + this.tvInfo = tvInfo; + } + + public boolean getCapabilityAttribute(String capability, String attribute) { + return Optional.ofNullable(deviceEvent).map(a -> a.getCapability()).filter(a -> a.equals(capability)) + .isPresent() + && Optional.ofNullable(deviceEvent).map(a -> a.getAttribute()).filter(a -> a.equals(attribute)) + .isPresent(); + } + + public String getSwitch() { + if (getCapabilityAttribute("switch", "switch")) { + return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).orElse(""); + } + return ""; + } + + public String getInputSource() { + if (getCapabilityAttribute("mediaInputSource", "inputSource") + || getCapabilityAttribute("samsungvd.mediaInputSource", "inputSource")) { + return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).orElse(""); + } + return ""; + } + + public String[] getInputSourceList() { + if (getCapabilityAttribute("mediaInputSource", "supportedInputSources")) { + return deviceEvent.getValuesAsList().toArray(String[]::new); + } + return new String[0]; + } + + public List getInputSourceMapList() { + if (getCapabilityAttribute("samsungvd.mediaInputSource", "supportedInputSourcesMap")) { + return deviceEvent.getValuesAsList(); + } + return List.of(); + } + + public int getInputSourceId() { + return this.tvInfo.map(t -> IntStream.range(0, t.getSources().length) + .filter(i -> t.getSources()[i].equals(getInputSource())).findFirst().orElse(-1)).orElse(-1); + } + + public Number getTvChannel() { + if (getCapabilityAttribute("tvChannel", "tvChannel")) { + return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).filter(i -> !i.isBlank()) + .map(j -> parseTVChannel(j)).orElse(-1f); + } + return -1; + } + + public String getTvChannelName() { + if (getCapabilityAttribute("tvChannel", "tvChannelName")) { + return Optional.ofNullable(deviceEvent).map(a -> a.getValue()).orElse(""); + } + return ""; + } + } + + public Number parseTVChannel(String channel) { + try { + return Optional.ofNullable(channel) + .map(a -> a.replaceAll("\\D+", ".").replaceFirst("^\\D*((\\d+\\.\\d+)|(\\d+)).*", "$1")) + .map(Float::parseFloat).orElse(-1f); + } catch (NumberFormatException ignore) { + } + return -1; + } + + public void updateTV() { + if (!tvInfo.isPresent()) { + fetchdata(); + tvInfo.ifPresent(t -> { + updateState(CHANNEL_NAME, t.getTvChannelName()); + updateState(CHANNEL, t.getTvChannel()); + updateState(SOURCE_NAME, t.getInputSource()); + updateState(SOURCE_ID, t.getInputSourceId()); + }); + } + } + + /** + * Smartthings API HTTP interface + * Currently rate limited to 350 requests/minute + * + * @param method the method "GET" or "POST" + * @param uri as a URI + * @param content to POST (or null) + * @return response + */ + public Optional sendUrl(HttpMethod method, URI uri, @Nullable InputStream content) throws IOException { + // need to add header "Authorization":"Bearer " + apiKey; + Properties headers = new Properties(); + headers.put("Authorization", "Bearer " + this.apiKey); + logger.trace("{}: Sending {}", host, uri.toURL().toString()); + Optional response = Optional.ofNullable(HttpUtil.executeUrl(method.toString(), uri.toURL().toString(), + headers, content, "application/json", TIMEOUT)); + if (!response.isPresent()) { + throw new IOException("No Data"); + } + response.ifPresent(r -> logger.trace("{}: Got response: {}", host, r)); + response.filter(r -> !r.startsWith("{")).ifPresent(r -> logger.debug("{}: Got response: {}", host, r)); + return response; + } + + /** + * Smartthings API HTTP getter + * Currently rate limited to 350 requests/minute + * + * @param value the query to send + * @return tvValues + */ + public synchronized Optional fetchTVProperties(String value) { + if (apiKey.isBlank()) { + return Optional.empty(); + } + Optional tvValues = Optional.empty(); + try { + String api = API_ENDPOINT_V1 + ((deviceId.isBlank()) ? "" : "devices/") + deviceId + value; + URI uri = new URI("https", null, SMARTTHINGS_URL, 443, api, null, null); + Optional response = sendUrl(HttpMethod.GET, uri, null); + tvValues = response.map(r -> new Gson().fromJson(r, TvValues.class)); + if (!tvValues.isPresent()) { + throw new IOException("No Data - is DeviceID correct?"); + } + tvValues.filter(t -> t.isError()).ifPresent(t -> logger.debug("{}: Error: {}", host, t.getError())); + errorCount = 0; + } catch (JsonSyntaxException | URISyntaxException | IOException e) { + logger.debug("{}: Cannot connect to Smartthings Cloud: {}", host, e.getMessage()); + if (errorCount++ > MAX_ERRORS) { + logger.warn("{}: Too many connection errors, disabling SmartThings", host); + stop(); + } + } + return tvValues; + } + + /** + * Smartthings API HTTP setter + * Currently rate limited to 350 requests/minute + * + * @param capability eg mediaInputSource + * @param command eg setInputSource + * @param value from acceptible list eg HDMI1, digitalTv, AM etc + * @return boolean true if successful + */ + public synchronized boolean setTVProperties(String capability, String command, String value) { + if (apiKey.isBlank() || deviceId.isBlank()) { + return false; + } + Optional response = Optional.empty(); + try { + String contentString = new Gson().toJson(new JSONContent(capability, command, value)); + logger.trace("{}: content: {}", host, contentString); + InputStream content = new ByteArrayInputStream(contentString.getBytes()); + String api = API_ENDPOINT_V1 + "devices/" + deviceId + COMMAND; + URI uri = new URI("https", null, SMARTTHINGS_URL, 443, api, null, null); + response = sendUrl(HttpMethod.POST, uri, content); + } catch (JsonSyntaxException | URISyntaxException | IOException e) { + logger.debug("{}: Send Command to Smartthings Cloud failed: {}", host, e.getMessage()); + } + return response.map(r -> r.contains("ACCEPTED") || r.contains("COMPLETED")).orElse(false); + } + + /** + * Smartthings API Subscription + * Retrieves the Smartthings API Subscription from a remote service, performing an API call + * + * @return stSub + */ + public synchronized Optional smartthingsSubscription() { + if (apiKey.isBlank() || deviceId.isBlank()) { + return Optional.empty(); + } + Optional stSub = Optional.empty(); + try { + logger.info("{}: SSE Creating Smartthings Subscription", host); + String contentString = new Gson().toJson(new JSONSubscriptionFilter(deviceId)); + logger.trace("{}: subscription: {}", host, contentString); + InputStream subscriptionFilter = new ByteArrayInputStream(contentString.getBytes()); + URI uri = new URI("https", null, SMARTTHINGS_URL, 443, "/subscriptions", null, null); + Optional response = sendUrl(HttpMethod.POST, uri, subscriptionFilter); + stSub = response.map(r -> new Gson().fromJson(r, STSubscription.class)); + if (!stSub.isPresent()) { + throw new IOException("No Data - is DeviceID correct?"); + } + } catch (JsonSyntaxException | URISyntaxException | IOException e) { + logger.warn("{}: SSE Subscription to Smartthings Cloud failed: {}", host, e.getMessage()); + } + return stSub; + } + + public synchronized void startSSE() { + if (!subscriptionRunning) { + logger.trace("{}: SSE Starting job", host); + subscription = smartthingsSubscription(); + logger.trace("{}: SSE got subscription ID: {}", host, + subscription.map(a -> a.getSubscriptionId()).orElse("None")); + if (!subscription.map(a -> a.getSubscriptionId()).orElse("").isBlank()) { + receiveSSEEvents(); + } + } + } + + public void stopSSE() { + handlerWrapper.ifPresent(a -> { + a.cancel(); + logger.trace("{}: SSE Stopping job", host); + handlerWrapper = Optional.empty(); + subscriptionRunning = false; + }); + } + + /** + * SubscriberWrapper needed to make async SSE stream cancelable + * + */ + @NonNullByDefault({}) + private static class SubscriberWrapper implements BodySubscriber { + private final CountDownLatch latch; + private final BodySubscriber subscriber; + private Subscription subscription; + + private SubscriberWrapper(BodySubscriber subscriber, CountDownLatch latch) { + this.subscriber = subscriber; + this.latch = latch; + } + + @Override + public CompletionStage getBody() { + return subscriber.getBody(); + } + + @Override + public void onSubscribe(Subscription subscription) { + subscriber.onSubscribe(subscription); + this.subscription = subscription; + latch.countDown(); + } + + @Override + public void onNext(List item) { + subscriber.onNext(item); + } + + @Override + public void onError(Throwable throwable) { + subscriber.onError(throwable); + } + + @Override + public void onComplete() { + subscriber.onComplete(); + } + + public void cancel() { + subscription.cancel(); + } + } + + @NonNullByDefault({}) + private static class BodyHandlerWrapper implements BodyHandler { + private final CountDownLatch latch = new CountDownLatch(1); + private final BodyHandler handler; + private SubscriberWrapper subscriberWrapper; + private int statusCode = -1; + + private BodyHandlerWrapper(BodyHandler handler) { + this.handler = handler; + } + + @Override + public BodySubscriber apply(ResponseInfo responseInfo) { + subscriberWrapper = new SubscriberWrapper(handler.apply(responseInfo), latch); + this.statusCode = responseInfo.statusCode(); + return subscriberWrapper; + } + + public void waitForEvent(boolean cancel) { + try { + CompletableFuture.runAsync(() -> { + try { + latch.await(); + if (cancel) { + subscriberWrapper.cancel(); + } + } catch (InterruptedException ignore) { + } + }).get(2, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException ignore) { + } + } + + public int getStatusCode() { + waitForEvent(false); + return statusCode; + } + + public void cancel() { + waitForEvent(true); + } + } + + public void receiveSSEEvents() { + subscription.ifPresent(sub -> { + updateTV(); + try { + URI uri = new URI(sub.getregistrationUrl()); + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(2)).GET() + .header("Authorization", "Bearer " + this.apiKey).build(); + handlerWrapper = Optional.ofNullable( + new BodyHandlerWrapper(HttpResponse.BodyHandlers.ofByteArrayConsumer(b -> processSSEEvent(b)))); + handlerWrapper.ifPresent(h -> { + client.sendAsync(request, h); + }); + logger.debug("{}: SSE job {}", host, checkResponseCode() ? "Started" : "Failed"); + } catch (URISyntaxException e) { + logger.warn("{}: SSE URI Exception: {}", host, e.getMessage()); + } + }); + } + + boolean checkResponseCode() { + int respCode = handlerWrapper.map(a -> a.getStatusCode()).orElse(-1); + logger.trace("{}: SSE GOT Response Code: {}", host, respCode); + subscriptionRunning = (respCode == 200); + return subscriptionRunning; + } + + Map bytesToMap(byte[] bytes) { + String s = new String(bytes, StandardCharsets.UTF_8); + // logger.trace("{}: SSE received: {}", host, s); + Map properties = new HashMap(); + String[] pairs = s.split("\r?\n"); + for (String pair : pairs) { + String[] kv = pair.split(":", 2); + properties.put(kv[0].trim(), kv[1].trim()); + } + logger.trace("{}: SSE received: {}", host, properties); + updateTV(); + return properties; + } + + synchronized void processSSEEvent(Optional bytes) { + bytes.ifPresent(b -> { + Map properties = bytesToMap(b); + String rawData = properties.getOrDefault("data", "none"); + String event = properties.getOrDefault("event", "none"); + // logger.trace("{}: SSE Decoding event: {}", host, event); + switch (event) { + case "CONTROL_EVENT": + subscriptionRunning = "welcome".equals(rawData); + if (!subscriptionRunning) { + logger.trace("{}: SSE Subscription ended", host); + startSSE(); + } + break; + case "DEVICE_EVENT": + try { + // decode json here + Optional data = Optional.ofNullable(new Gson().fromJson(rawData, STSSEData.class)); + data.ifPresentOrElse(d -> { + d.setTvInfo(tvInfo); + String[] inputList = d.getInputSourceList(); + if (inputList.length > 0) { + logger.trace("{}: SSE Got input source list: {}", host, Arrays.asList(inputList)); + tvInfo.ifPresent(a -> a.updateSupportedInputSources(inputList)); + } + String inputSource = d.getInputSource(); + if (!inputSource.isBlank()) { + updateState(SOURCE_NAME, inputSource); + int sourceId = d.getInputSourceId(); + logger.trace("{}: SSE Got input source: {} ID: {}", host, inputSource, sourceId); + updateState(SOURCE_ID, sourceId); + } + Number tvChannel = d.getTvChannel(); + if (tvChannel.intValue() != -1) { + updateState(CHANNEL, tvChannel); + String tvChannelName = d.getTvChannelName(); + logger.trace("{}: SSE Got TV Channel Name: {} Channel: {}", host, tvChannelName, + tvChannel); + updateState(CHANNEL_NAME, tvChannelName); + } + String Power = d.getSwitch(); + if (!Power.isBlank()) { + logger.debug("{}: SSE Got TV Power: {}", host, Power); + if ("on".equals(Power)) { + // handler.putOnline(); // ignore on event for now + } else { + // handler.setOffline(); // ignore off event for now + } + } + }, () -> logger.warn("{}: SSE Received NULL data", host)); + } catch (JsonSyntaxException e) { + logger.warn("{}: SmartThingsApiService: Error ({}) in message: {}", host, e.getMessage(), + rawData); + } + break; + default: + logger.trace("{}: SSE not handling event: {}", host, event); + break; + } + }); + } + + private boolean updateDeviceID(TvValues.Items item) { + this.deviceId = item.getDeviceId(); + logger.debug("{}: found {} device, adding device id {}", host, item.getName(), deviceId); + handler.putConfig(SMARTTHINGS_DEVICEID, deviceId); + prevUpdate = 0; + return true; + } + + public boolean fetchdata() { + if (System.currentTimeMillis() >= prevUpdate + RATE_LIMIT) { + if (deviceId.isBlank()) { + tvInfo = fetchTVProperties(DEVICES); + boolean found = false; + if (tvInfo.isPresent()) { + TvValues t = tvInfo.get(); + switch (t.getItems().length) { + case 0: + case 1: + logger.warn("{}: No devices found - please add your TV to the Smartthings app", host); + break; + case 2: + found = Arrays.asList(t.getItems()).stream().filter(a -> "Samsung TV".equals(a.getName())) + .map(a -> updateDeviceID(a)).findFirst().orElse(false); + break; + default: + logger.warn("{}: No device Id selected, please enter one of the following:", host); + Arrays.asList(t.getItems()).stream().forEach(a -> logger.info("{}: '{}' : {}({})", host, + a.getDeviceId(), a.getName(), a.getLabel())); + } + } + if (found) { + return fetchdata(); + } else { + stop(); + return false; + } + } + tvInfo = fetchTVProperties(COMPONENTS); + prevUpdate = System.currentTimeMillis(); + } + return (tvInfo.isPresent()); + } + + @Override + public void start() { + online = true; + errorCount = 0; + startSSE(); + } + + @Override + public void stop() { + online = false; + stopSSE(); + } + + @Override + public void clearCache() { + stateMap.clear(); + start(); + } + + @Override + public boolean isUpnp() { + return false; + } + + @Override + public boolean checkConnection() { + return online; + } + + @Override + public boolean handleCommand(String channel, Command command) { + logger.trace("{}: Received channel: {}, command: {}", host, channel, command); + if (!checkConnection()) { + logger.trace("{}: Smartthings offline", host); + return false; + } + + if (fetchdata()) { + return tvInfo.map(t -> { + boolean result = false; + if (command == RefreshType.REFRESH) { + switch (channel) { + case CHANNEL_NAME: + updateState(CHANNEL_NAME, t.getTvChannelName()); + break; + case CHANNEL: + updateState(CHANNEL, t.getTvChannel()); + break; + case SOURCE_ID: + case SOURCE_NAME: + updateState(SOURCE_NAME, t.getInputSource()); + updateState(SOURCE_ID, t.getInputSourceId()); + break; + default: + break; + } + return true; + } + + switch (channel) { + case SOURCE_ID: + if (command instanceof DecimalType commandAsDecimalType) { + int val = commandAsDecimalType.intValue(); + if (val >= 0 && val < t.getSources().length) { + result = setSourceName(t.getSources()[val]); + } else { + logger.warn("{}: Invalid source ID: {}, acceptable: 0..{}", host, command, + t.getSources().length); + } + } + break; + case SOURCE_NAME: + if (command instanceof StringType) { + if (t.getSourcesString().contains(command.toString()) || t.getSourcesString().isBlank()) { + result = setSourceName(command.toString()); + } else { + logger.warn("{}: Invalid source Name: {}, acceptable: {}", host, command, + t.getSourcesString()); + } + } + break; + default: + logger.warn("{}: Samsung TV doesn't support transmitting for channel '{}'", host, channel); + } + if (!result) { + logger.warn("{}: Smartthings: wrong command type {} channel {}", host, command, channel); + } + return result; + }).orElse(false); + } + return false; + } + + private void updateState(String channel, Object value) { + if (!stateMap.getOrDefault(channel, "None").equals(value)) { + switch (channel) { + case CHANNEL: + case SOURCE_ID: + handler.valueReceived(channel, new DecimalType((Number) value)); + break; + default: + handler.valueReceived(channel, new StringType((String) value)); + break; + } + stateMap.put(channel, value); + } else { + logger.trace("{}: Value '{}' for {} hasn't changed, ignoring update", host, value, channel); + } + } + + private boolean setSourceName(String value) { + return setTVProperties("mediaInputSource", "setInputSource", value); + } +} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/EventListener.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/EventListener.java deleted file mode 100644 index 7c4c440971849..0000000000000 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/EventListener.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) 2010-2024 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.samsungtv.internal.service.api; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.io.net.http.WebSocketFactory; -import org.openhab.core.thing.ThingStatusDetail; -import org.openhab.core.types.State; - -/** - * Interface for receiving data from Samsung TV services. - * - * @author Pauli Anttila - Initial contribution - * @author Arjan Mels - Added methods to put/get configuration - */ -@NonNullByDefault -public interface EventListener { - /** - * Invoked when value is received from the TV. - * - * @param variable Name of the variable. - * @param value Value of the variable value. - */ - void valueReceived(String variable, State value); - - /** - * Report an error to this event listener - * - * @param statusDetail hint about the actual underlying problem - * @param message of the error - * @param e exception that might have occurred - */ - void reportError(ThingStatusDetail statusDetail, String message, Throwable e); - - /** - * Get configuration item - * - * @param key key of configuration item - * @param value value of key - */ - void putConfig(String key, Object value); - - /** - * Put configuration item - * - * @param key key of configuration item - * @return value of key - */ - Object getConfig(String key); - - /** - * Get WebSocket Factory - * - * @return WebSocket Factory - */ - WebSocketFactory getWebSocketFactory(); -} diff --git a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/SamsungTvService.java b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/SamsungTvService.java old mode 100644 new mode 100755 index 4318396e5c94a..dc89a086ab950 --- a/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/SamsungTvService.java +++ b/bundles/org.openhab.binding.samsungtv/src/main/java/org/openhab/binding/samsungtv/internal/service/api/SamsungTvService.java @@ -21,6 +21,7 @@ * Interface for Samsung TV services. * * @author Pauli Anttila - Initial contribution + * @author Nick Waterton - add checkConnection(), getServiceName(), refactoring */ @NonNullByDefault public interface SamsungTvService { @@ -30,7 +31,7 @@ public interface SamsungTvService { * * @return List of supported */ - List getSupportedChannelNames(); + List getSupportedChannelNames(boolean refresh); /** * Procedure for sending command. @@ -38,23 +39,7 @@ public interface SamsungTvService { * @param channel the channel to which the command applies * @param command the command to be handled */ - void handleCommand(String channel, Command command); - - /** - * Procedure for register event listener. - * - * @param listener - * Event listener instance to handle events. - */ - void addEventListener(EventListener listener); - - /** - * Procedure for remove event listener. - * - * @param listener - * Event listener instance to remove. - */ - void removeEventListener(EventListener listener); + boolean handleCommand(String channel, Command command); /** * Procedure for starting service. @@ -80,4 +65,18 @@ public interface SamsungTvService { * @return whether this service is an UPnP configured / discovered service */ boolean isUpnp(); + + /** + * Is service connected + * + * @return whether this service is connected or not + */ + boolean checkConnection(); + + /** + * get service name. + * + * @return String SERVICE_NAME + */ + String getServiceName(); } diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/addon/addon.xml old mode 100644 new mode 100755 index ded27a6f1d4b0..ab93c855f8dc3 --- a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/addon/addon.xml +++ b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/addon/addon.xml @@ -4,9 +4,8 @@ xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd"> binding - Samsung TV Binding - This is the binding for Samsung TV. Binding should support all Samsung TV C (2010), D (2011) and E (2012) - models + SamsungTV Binding + This is the binding for Samsung TV. local diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/config/config.xml old mode 100644 new mode 100755 index d122cd5fb1839..765892be44304 --- a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/config/config.xml @@ -5,6 +5,14 @@ xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd"> + + + + This enables the Input Source and Channel Number channels on TV's that don't support this locally, by + connecting wth the Smartthings cloud API. Only available with WebSocket and SecureWebSocket Protocols. + + true + Network address of the Samsung TV. @@ -12,7 +20,7 @@ - TCP port of the Samsung TV. + TCP port of the Samsung TV (legacy: 1515, 7001, 15500, 55000 websockets: 8001, 8002). 55000 @@ -41,6 +49,22 @@ Security token for secure websocket connection true + + + Reduces polling on UPNP devices, but may be unreliable, disable if you have problems + false + true + + + + Go to https://account.smartthings.com/tokens and obtain a Personal Access Token, enter it here. + true + + + + Once your PAT is entered and saved, look in the log for the Device ID for this TV, enter it here. + true + diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv.properties b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv.properties old mode 100644 new mode 100755 index 44ce03b37528c..0aa5992c76c50 --- a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv.properties +++ b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv.properties @@ -1,7 +1,7 @@ # add-on -addon.samsungtv.name = Samsung TV Binding -addon.samsungtv.description = This is the binding for Samsung TV. Binding should support all Samsung TV C (2010), D (2011) and E (2012) models +addon.samsungtv.name = SamsungTV Binding +addon.samsungtv.description = This is the binding for Samsung TV. # thing types @@ -10,12 +10,14 @@ thing-type.samsungtv.tv.description = Allows to control Samsung TV # thing types config +thing-type.config.samsungtv.tv.group.Cloud Connection.label = Smartthings Connection +thing-type.config.samsungtv.tv.group.Cloud Connection.description = This enables the Input Source and Channel Number channels on TV's that don't support this locally, by connecting wth the Smartthings cloud API. Only available with WebSocket and SecureWebSocket Protocols. thing-type.config.samsungtv.tv.hostName.label = Host Name thing-type.config.samsungtv.tv.hostName.description = Network address of the Samsung TV. thing-type.config.samsungtv.tv.macAddress.label = MAC Address thing-type.config.samsungtv.tv.macAddress.description = MAC Address of the Samsung TV. thing-type.config.samsungtv.tv.port.label = TCP Port -thing-type.config.samsungtv.tv.port.description = TCP port of the Samsung TV. +thing-type.config.samsungtv.tv.port.description = TCP port of the Samsung TV (legacy: 1515, 7001, 15500, 55000 websockets: 8001, 8002). thing-type.config.samsungtv.tv.protocol.label = Remote Control Protocol thing-type.config.samsungtv.tv.protocol.description = The type of remote control protocol. This depends on the age of the TV. thing-type.config.samsungtv.tv.protocol.option.None = None @@ -24,6 +26,12 @@ thing-type.config.samsungtv.tv.protocol.option.WebSocket = Websocket (2016 and l thing-type.config.samsungtv.tv.protocol.option.SecureWebSocket = Secure websocket (2016 and later TV's) thing-type.config.samsungtv.tv.refreshInterval.label = Refresh Interval thing-type.config.samsungtv.tv.refreshInterval.description = States how often a refresh shall occur in milliseconds. +thing-type.config.samsungtv.tv.smartThingsApiKey.label = Smartthings PAT +thing-type.config.samsungtv.tv.smartThingsApiKey.description = Go to https://account.smartthings.com/tokens and obtain a Personal Access Token, enter it here. +thing-type.config.samsungtv.tv.smartThingsDeviceId.label = Smartthings Device ID +thing-type.config.samsungtv.tv.smartThingsDeviceId.description = Once your PAT is entered and saved, look in the log for the Device ID for this TV, enter it here. +thing-type.config.samsungtv.tv.subscription.label = Subscribe to UPNP +thing-type.config.samsungtv.tv.subscription.description = Reduces polling on UPNP devices, but may be unreliable, disable if you have problems thing-type.config.samsungtv.tv.webSocketToken.label = Websocket Token thing-type.config.samsungtv.tv.webSocketToken.description = Security token for secure websocket connection @@ -31,6 +39,16 @@ thing-type.config.samsungtv.tv.webSocketToken.description = Security token for s channel-type.samsungtv.artmode.label = Art Mode channel-type.samsungtv.artmode.description = TV Art Mode. +channel-type.samsungtv.artwork.label = Art Selected +channel-type.samsungtv.artwork.description = Set/get Artwork that will be displayed in artMode +channel-type.samsungtv.artworkbrightness.label = Artwork Brightness +channel-type.samsungtv.artworkbrightness.description = Set/get brightness of the artwork displayed +channel-type.samsungtv.artworkcolortemperature.label = Artwork Color Temperature +channel-type.samsungtv.artworkcolortemperature.description = Set/get color temperature of the artwork displayed. Minimum value is -5 and maximum 5. +channel-type.samsungtv.artworkjson.label = Artwork Json +channel-type.samsungtv.artworkjson.description = Send and receive JSON from the TV Art channel +channel-type.samsungtv.artworklabel.label = Artwork Label +channel-type.samsungtv.artworklabel.description = Set/get label of the artwork to be displayed channel-type.samsungtv.brightness.label = Brightness channel-type.samsungtv.brightness.description = Brightness of the TV picture. channel-type.samsungtv.channel.label = Channel @@ -61,6 +79,7 @@ channel-type.samsungtv.keycode.state.option.KEY_16_9 = KEY_16_9 channel-type.samsungtv.keycode.state.option.KEY_AD = KEY_AD channel-type.samsungtv.keycode.state.option.KEY_ADDDEL = KEY_ADDDEL channel-type.samsungtv.keycode.state.option.KEY_ALT_MHP = KEY_ALT_MHP +channel-type.samsungtv.keycode.state.option.KEY_AMBIENT = KEY_AMBIENT channel-type.samsungtv.keycode.state.option.KEY_ANGLE = KEY_ANGLE channel-type.samsungtv.keycode.state.option.KEY_ANTENA = KEY_ANTENA channel-type.samsungtv.keycode.state.option.KEY_ANYNET = KEY_ANYNET @@ -101,6 +120,7 @@ channel-type.samsungtv.keycode.state.option.KEY_AV2 = KEY_AV2 channel-type.samsungtv.keycode.state.option.KEY_AV3 = KEY_AV3 channel-type.samsungtv.keycode.state.option.KEY_BACK_MHP = KEY_BACK_MHP channel-type.samsungtv.keycode.state.option.KEY_BOOKMARK = KEY_BOOKMARK +channel-type.samsungtv.keycode.state.option.KEY_BT_VOICE = KEY_BT_VOICE channel-type.samsungtv.keycode.state.option.KEY_CALLER_ID = KEY_CALLER_ID channel-type.samsungtv.keycode.state.option.KEY_CAPTION = KEY_CAPTION channel-type.samsungtv.keycode.state.option.KEY_CATV_MODE = KEY_CATV_MODE @@ -209,6 +229,7 @@ channel-type.samsungtv.keycode.state.option.KEY_MORE = KEY_MORE channel-type.samsungtv.keycode.state.option.KEY_MOVIE1 = KEY_MOVIE1 channel-type.samsungtv.keycode.state.option.KEY_MS = KEY_MS channel-type.samsungtv.keycode.state.option.KEY_MTS = KEY_MTS +channel-type.samsungtv.keycode.state.option.KEY_MULTI_VIEW = KEY_MULTI_VIEW channel-type.samsungtv.keycode.state.option.KEY_MUTE = KEY_MUTE channel-type.samsungtv.keycode.state.option.KEY_NINE_SEPERATE = KEY_NINE_SEPERATE channel-type.samsungtv.keycode.state.option.KEY_OPEN = KEY_OPEN @@ -293,6 +314,8 @@ channel-type.samsungtv.power.label = Power channel-type.samsungtv.power.description = TV power. Some of the Samsung TV models doesn't allow to set Power ON remotely. channel-type.samsungtv.programtitle.label = Program Title channel-type.samsungtv.programtitle.description = Program title of the current channel. +channel-type.samsungtv.setartmode.label = Set Art Mode +channel-type.samsungtv.setartmode.description = Set ArtMode ON/OFF from an external source (needed for >=2022 Frame TV's) channel-type.samsungtv.sharpness.label = Sharpness channel-type.samsungtv.sharpness.description = Sharpness of the TV picture. channel-type.samsungtv.sourceapp.label = Application diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv_de.properties b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv_de.properties old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv_hu.properties b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv_hu.properties old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv_it.properties b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/i18n/samsungtv_it.properties old mode 100644 new mode 100755 diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/channel-types.xml b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/channel-types.xml old mode 100644 new mode 100755 index 08b8b4b567538..98e3056ebef5d --- a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/channel-types.xml +++ b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/channel-types.xml @@ -103,6 +103,43 @@ TV Art Mode. + + Switch + + Set ArtMode ON/OFF from an external source (needed for >=2022 Frame TV's) + + + + Image + + Set/get Artwork that will be displayed in artMode + + + + String + + Set/get label of the artwork to be displayed + + + + String + + Send and receive JSON from the TV Art channel + + + + Dimmer + + Set/get brightness of the artwork displayed + + + + Number + + Set/get color temperature of the artwork displayed. Minimum value is -5 and + maximum 5. + + String @@ -135,6 +172,7 @@ + @@ -175,6 +213,7 @@ + @@ -283,6 +322,7 @@ + diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/thing-types.xml old mode 100644 new mode 100755 index a06e47b38a081..638d83222380c --- a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/thing/thing-types.xml @@ -23,11 +23,21 @@ + - + + + + + + + + 1 + + hostName diff --git a/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/update/instructions.xml b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/update/instructions.xml new file mode 100755 index 0000000000000..3547215823efd --- /dev/null +++ b/bundles/org.openhab.binding.samsungtv/src/main/resources/OH-INF/update/instructions.xml @@ -0,0 +1,35 @@ + + + + + + + samsungtv:power + + + samsungtv:artmode + + + samsungtv:setartmode + + + samsungtv:artwork + + + samsungtv:artworklabel + + + samsungtv:artworkjson + + + samsungtv:artworkbrightness + + + samsungtv:artworkcolortemperature + + + + +