# Nest Binding
-The Nest binding integrates devices by [Nest](https://nest.com) using the [Nest API](https://developers.nest.com/documentation/cloud/get-started) (REST).
+The Nest binding integrates devices by [Nest](https://store.google.com/us/category/connected_home?) using the [Smart Device Management](https://developers.google.com/nest/device-access/api) (SDM) API and the Works with Nest (WWN) API.
-Because the Nest API runs on Nest's servers a connection with the Internet is required for sending and receiving information.
-The binding uses HTTPS to connect to the Nest API using ports 443 and 9553. Make sure outbound connections to these ports are not blocked by a firewall.
+To be able to use the SDM API it is required to first [register](https://developers.google.com/nest/device-access/registration) and pay a US$5 non-refundable registration fee.
-> Note: This binding can only be used with Nest devices if you have an existing Nest developer account signed up for the Works with Nest (WWN) program.
-New integrations using the WWN program are no longer accepted because WWN is being retired.
-To keep using this binding do **NOT** migrate your Nest Account to a Google Account.
-For more information see [What's happening at Nest?](https://nest.com/whats-happening/).
+It is also possible to use the older WWN API with this binding.
+For this you need to have the account details of a previously registered WWN API account.
+Another requirement is that you have not yet migrated your Nest account to a Google account (which is irreversible).
+It is no longer possible to register new WWN API accounts because the WWN API runs in maintenance mode.
+See also [What's happening at Nest?](https://nest.com/whats-happening/).
+
+Because the SDM and WWN APIs run on servers in the cloud, a connection with the Internet is required for sending and receiving information.
+The binding uses HTTPS to connect to the APIs using port 443.
+When using the WWN API, the binding also connects to servers on port 9553.
+So make sure outbound connections to these ports are not blocked by a firewall.
## Supported Things
The table below lists the Nest binding thing types:
-| Things | Description | Thing Type |
-|-----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|----------------|
-| Nest Account | An account for using the Nest REST API | account |
-| Nest Cam (Indoor, IQ, Outdoor), Dropcam | A Nest Cam registered with your account | camera |
-| Nest Protect | The smoke detector/Nest Protect for the account | smoke_detector |
-| Structure | The Nest structure defines the house the account has setup on Nest. You will only have more than one structure if you have more than one house | structure |
-| Nest Thermostat (E) | A Thermostat to control the various aspects of the house's HVAC system | thermostat |
-
-## Authorization
-
-The Nest API uses OAuth for authorization.
-Therefore the binding needs some authorization parameters before it can access your Nest account via the Nest API.
-
-To get these authorization parameters you first need to sign up as a [Nest Developer](https://developer.nest.com) and [register a new Product](https://developer.nest.com/products/new) (free and instant).
-
-While registering a new Product (on the Product Details page) make sure to:
-
-* Leave both "OAuth Redirect URI" fields empty to enable PIN-based authorization.
-* Grant all the permissions you intend to use. When in doubt, enable the permission because the binding needs to be reauthorized when permissions change at a later time.
-
-After creating the Product, your browser shows the Product Overview page.
-This page contains the **Product ID** and **Product Secret** authorization parameters that are used by the binding.
-Take note of both parameters or keep this page open in a browser tab.
-Now copy and paste the "Authorization URL" in a new browser tab.
-Accept the permissions and you will be presented the **Pincode** authorization parameter that is also used by the binding.
-
-You can return to the Product Overview page at a later time by opening the [Products](https://console.developers.nest.com/products) page and selecting your Product.
+| Things | Description | SDM Thing Type | WWN Thing Type |
+|-----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|----------------|--------------------|
+| Nest Account (SDM, WWN) | An account for using the Nest (SDM/WWN) REST API | sdm_account | wwn_account |
+| Nest Cam (Indoor, IQ, Outdoor), Dropcam | A Nest Cam registered with your account | sdm_camera | wwn_camera |
+| Nest Hello Doorbell | A Nest Doorbell registered with your account | sdm_doorbell | wwn_camera |
+| Nest Hub (Max) | A Nest Display registered with your account | sdm_display | wwn_camera |
+| Nest Protect | The smoke detector/Nest Protect for the account | | wwn_smoke_detector |
+| Nest Thermostat (E) | A Thermostat to control the various aspects of the house's HVAC system | sdm_thermostat | wwn_thermostat |
+| Structure | The Nest structure defines the house the account has setup on Nest. You will only have more than one structure if you have more than one house | | wwn_structure |
+
+The SDM API currently does not support Nest Protect devices.
+There are no structure Things when using the SDM API, because the SDM API does not support setting the Home/Away status like the WWN API does.
+
+To use one of the Nest APIs, add the corresponding Account Thing using the UI and configure the required parameters.
+After configuring an Account Thing, you can use it to discover the connected devices which are then added the Inbox.
+
+## SDM Account Configuration
+
+### Google Account Requirement
+
+To be able to use the SDM API it is required that you use a Google Account with your Nest devices.
+If you still use the WWN API, you can no longer use the WWN API after migrating to a Google Account.
+So if you have not yet migrated your account, check that all the functionality you require is provided by the SDM API and SDM Things in the binding.
+Most notably, there is no support for the Nest Protect in the SDM API and you cannot change your Home/Away status.
+To migrate to a Google account, follow the migration steps in the [Nest accounts FAQ](https://support.google.com/googlenest/answer/9297676?co=GENIE.Platform%3DiOS&hl=en&oco=0#accountmigration&accountmigration1&#accountmigration2&#accountmigration3&zippy=%2Chow-do-i-migrate-my-account)
+
+### SDM Configuration Parameters
+
+These parameters configure which SDM project is accessed using the SDM API and configure the OAuth 2.0 client details used for accessing the project.
+
+First a SDM project needs to be created and configured:
+
+1. Register for device access by clicking the "Go to Device Access Console" button and follow the instructions on the [Device Access Registration](https://developers.google.com/nest/device-access/registration) page.
+1. Create a new SDM project on the [Projects](https://console.nest.google.com/device-access/project-list) page
+ 1. Give your project a name so it is easily recognizable
+ 1. "Skip" entering the OAuth client ID for now
+ 1. If you want to download camera images using the binding, it is required to "Enable" events.
+ Enabling events also allows for faster thermostat state updates.
+ The binding only uses events when the Pub/Sub configuration parameters of the Nest SDM Account Thing are also configured.
+ 1. After clicking the "Create project" button, the SDM project details of the created project show
+1. Copy and save the **Project ID** at the top of the page (e.g. `585de72e-968c-435c-b16a-31d1d3f76833`) somewhere
+
+Now an OAuth 2.0 client is created and configured for using the SDM API by the binding:
+
+1. Configure the "Publishing status" of your Google Cloud Platform to "Production" ([APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)) so the OAuth 2.0 tokens do not expire after 2 weeks
+1. Create a new client on the "Credentials" page ([APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)):
+ 1. Click the "Create Credentials" button at the top of the page
+ 1. Choose "OAuth client ID"
+ 1. As "Application type" choose "TVs and Limited Input devices"
+ 1. Give it a name so you can remember what it is used for (e.g. `Nest Binding SDM`)
+ 1. Click "Create" to create the client
+ 1. Copy and save the generated **Client ID** (e.g. `1046297811237-3f5sj4ccfubit0fum027ral82jgffsd1.apps.googleusercontent.com`) and **Client Secret** (e.g. `726kcU-d1W4RXxEJA79oZ0oG`) somewhere
+1. Configure the SDM project to use the created client:
+ 1. Go the the SDM [Projects](https://console.nest.google.com/device-access/project-list) page
+ 1. Click on your SDM Project to show its details
+ 1. Scroll to "Project Info > OAuth client ID" and open the options menu (3 stacked dots) at the end of the line
+ 1. Select the "Edit" option
+ 1. Copy/paste the saved OAuth 2.0 Client ID here (e.g. `1046297811237-3f5sj4ccfubit0fum027ral82jgffsd1.apps.googleusercontent.com`)
+ 1. Click the "Save" button at the end of the line to update the project
+
+Finally, an SDM Account Thing can be created to access the SDM project using the SDM API with the created client:
+
+1. Create a new "Nest SDM Account" Thing in openHAB
+1. Copy/paste the saved SDM **Project ID** to SDM group parameter in the SDM Account Thing configuration parameters (e.g. `585de72e-968c-435c-b16a-31d1d3f76833`)
+1. Copy/paste the saved OAuth 2.0 **Client ID** to SDM group parameter (e.g. `1046297811237-3f5sj4ccfubit0fum027ral82jgffsd1.apps.googleusercontent.com`)
+1. Copy/paste the saved OAuth 2.0 **Client Secret** to SDM group parameter (e.g. `726kcU-d1W4RXxEJA79oZ0oG`)
+1. Create an authorization code for the binding:
+ 1. Replace the **Project ID** and **Client ID** in the URL below with your SDM Project ID and SDM OAuth 2.0 Client ID and open the URL in a new browser tab:
+
+ `https://nestservices.google.com/partnerconnections/{{ProjectID}}/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&access_type=offline&prompt=consent&client_id={{ClientID}}&response_type=code&scope=https://www.googleapis.com/auth/sdm.service`
+
+ For the example values used so far this is:
+
+ `https://nestservices.google.com/partnerconnections/585de72e-968c-435c-b16a-31d1d3f76833/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&access_type=offline&prompt=consent&client_id=1046297811237-3f5sj4ccfubit0fum027ral82jgffsd1.apps.googleusercontent.com&response_type=code&scope=https://www.googleapis.com/auth/sdm.service`
+ 1. Enable all the permissions you want to use with the binding and click "Next" to continue
+ 1. Login using your Google account when prompted
+ 1. On the "Google hasn't verified this app" page, click on "Advanced"
+ 1. Then click on "Go to ... (advanced)"
+ 1. Now "Allow" the SDM permissions and confirm your choices again by clicking "Allow"
+ 1. Next the "Sign in" page will show the **Authorization Code**
+ 1. Copy/paste the **Authorization Code** to the SDM group parameter in the openHAB Nest SDM Account Thing configuration
+1. All required SDM Account Thing configuration parameters have now been entered so create it by clicking "Create Thing".
+
+The SDM Account Thing should now be ONLINE and have as status description "Using periodic refresh".
+It should also be possible to use the configured account to discover your Nest devices via the Inbox.
+
+You can monitor the SDM API using the Google Cloud Platform Console via [API & Services > Smart Device Management API](https://console.cloud.google.com/apis/api/smartdevicemanagement.googleapis.com/overview).
+
+If you've made it this far, it should be easy to edit the SDM Account Thing again and update it so it can also use SDM Pub/Sub events. :-)
+
+### Pub/Sub Configuration Parameters
+
+After configuring the SDM configuration parameters, a SDM Account Thing can be updated so it can listen to SDM events using Pub/Sub.
+This is required if you want to download camera images using the binding or to get faster thermostat state updates.
+
+Enable Pub/Sub events in your SDM project:
+
+1. Open your SDM project details using the [Projects](https://console.nest.google.com/device-access/project-list) page
+1. Scroll to "Project Info > Pub/Sub topic" and check if it is set to "Enabled"
+1. If it is set to "Disabled", enable events:
+ 1. Open the options menu (3 stacked dots) at the end of the line
+ 1. Select the "Edit" option
+ 1. Check the "Enable events" option
+ 1. Click the "Save" button at the end of the line to update the project
+
+Lookup your Google Cloud Platform (GCP) Project ID:
+
+1. Open the [IAM & Admin > Settings](https://console.cloud.google.com/iam-admin/settings)
+1. Copy and save the GCP **Project ID** (e.g. `openhab-12345`)
+
+Next an OAuth 2.0 client is created which is used to create a Pub/Sub subscription for listening to SDM events by the binding:
+
+1. Open the "Credentials" page ([APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)):
+1. Click the "Create Credentials" button at the top of the page
+1. Choose "OAuth client ID"
+1. As "Application type" choose "TVs and Limited Input devices"
+1. Give it a name so you can remember what it is used for (e.g. `Nest Binding Pub/Sub`)
+1. Click "Create" to create the client
+1. Copy and save the generated **Client ID** (e.g. `1046297811237-lg27h26kln6r1nbg54jpg6nfjg6h4b3n.apps.googleusercontent.com`) and **Client Secret** (e.g. `1-k78-XcHhp_gdZF-I6JaIHp`) somewhere
+
+Finally, the existing SDM Account Thing can be updated so it can subscribe to SDM events:
+
+1. Open the configuration details of your existing "Nest SDM Account" Thing in openHAB
+1. Copy/paste the saved GCP **Project ID** to Pub/Sub group parameter (e.g. `openhab-123`)
+1. Enter a name in **Subscription ID** that uniquely identifies the Pub/Sub subscription used by the binding
+
+ > Must be 3-255 characters, start with a letter, and contain only the following characters: letters, numbers, dashes (-), periods (.), underscores (_), tildes (~), percents (%) or plus signs (+). Cannot start with goog.
+1. Copy/paste the saved OAuth 2.0 **Client ID** to Pub/Sub group parameter (e.g. `1046297811237-lg27h26kln6r1nbg54jpg6nfjg6h4b3n.apps.googleusercontent.com`)
+1. Copy/paste the saved OAuth 2.0 **Client Secret** to Pub/Sub group parameter (e.g. `1-k78-XcHhp_gdZF-I6JaIHp`)
+1. Create an authorization code for the binding:
+ 1. Replace the **Client ID** in the URL below with your Pub/Sub OAuth 2.0 Client ID and open the URL in a new browser tab:
+
+ `https://accounts.google.com/o/oauth2/auth?client_id={{ClientID}}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/pubsub`
+
+ For the example client this is:
+
+ `https://accounts.google.com/o/oauth2/auth?client_id=1046297811237-lg27h26kln6r1nbg54jpg6nfjg6h4b3n.apps.googleusercontent.com&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/pubsub`
+ 1. Login using your Google account when prompted
+ 1. On the "Google hasn't verified this app" page, click on "Advanced"
+ 1. Then click on "Go to ... (advanced)"
+ 1. Now "Allow" the Pub/Sub permissions and confirm your choices again by clicking "Allow"
+ 1. Next the "Sign in" page will show the **Authorization Code**
+ 1. Copy/paste the **Authorization Code** to the Pub/Sub group parameter in the openHAB Nest SDM Account Thing configuration
+1. All required Pub/Sub Account Thing configuration parameters have now been entered so click "Save" to update the SDM Account Thing configuration.
+
+The SDM Account Thing should now be ONLINE and have as status description "Using periodic refresh and Pub/Sub".
+
+The created subscription can also be monitored using the Google Cloud Platform Console via [Pub/Sub > Subscriptions](https://console.cloud.google.com/cloudpubsub/subscription/list).
+
+## SDM Device Configuration
+
+| Configuration Parameter | Required | Default | Description |
+|-------------------------|----------|---------|---------------------------------------------------------------------------|
+| deviceId | X | | Identifies the device in the SDM API |
+| refreshInterval | | 300 | This is refresh interval in seconds to update the Nest device information |
+
+Decreasing the `refreshInterval` may cause issues when you have a lot of devices connected because it may cause API rate limits to be exceeded.
+You may want to decrease the `refreshInterval` for a Thermostat if Pub/Sub events have not been configured to provide state updating.
+
+## WWN Account Configuration
+
+To configure the binding to use the WWN API, add a new "Nest WWN Account" Thing in the UI and enter the **Product ID**, **Product Secret** and **Access Token** of an existing WWN account as configuration parameters.
+It is no longer possible to register new WWN accounts with Nest because the WWN API runs in maintenance mode.
## Discovery
-The binding will discover all Nest Things from your account when you add and configure a "Nest Account" Thing.
-See the Authorization paragraph above for details on how to obtain the Product ID, Product Secret and Pincode configuration parameters.
-
-Once the binding has successfully authorized with the Nest API, it obtains an Access Token using the Pincode.
-The configured Pincode is cleared because it can only be used once.
-The obtained Access Token is saved as an advanced configuration parameter of the "Nest Account".
-
-You can reuse an Access Token for authorization but not the Pincode.
-A new Pincode can again be generated via the "Authorization URL" (see Authorization paragraph).
+The binding will discover all Nest Things from your account when you add and configure a Nest SDM or WWN Account Thing.
## Channels
-### Account Channels
-
-The account Thing Type does not have any channels.
-
-### Camera Channels
+### SDM/WWN Account Channels
+
+The account Thing Types do not have any channels.
+
+### SDM Camera/Display/Doorbell Channels
+
+The state of these channels is based on Pub/Sub events sent by the SDM API.
+So make sure the Pub/Sub account details are properly configured in the `sdm_account`.
+
+| Channel Type ID | Item Type | Description | Read Write |
+|----------------------------------|-----------|-----------------------------------------------------|:----------:|
+| chime_event#image | Image | Static image based on a chime event | R |
+| chime_event#timestamp | DateTime | The last time that the door chime was pressed | R |
+| live_stream#current_token | String | Live stream current token value | R |
+| live_stream#expiration_timestamp | DateTime | Live stream token expiration time | R |
+| live_stream#extension_token | String | Live stream token extension value | R |
+| live_stream#url | String | The RTSP video stream URL for the most recent event | R |
+| motion_event#image | Image | Static image based on a motion event | R |
+| motion_event#timestamp | DateTime | The last time that motion was detected | R |
+| person_event#image | Image | Static image based on a person event | R |
+| person_event#timestamp | DateTime | The last time that a person was detected | R |
+| sound_event#image | Image | Static image based on a sound event | R |
+| sound_event#timestamp | DateTime | The last time that a sound was detected | R |
+
+The `chime_event` group channels only exist for doorbell Things.
+Each image channel has the `imageWidth` and `imageHeight` configuration parameters that can be used for configuring the image size in pixels.
+The maximum camera resolution is listed as `maxImageResolution` property in the Thing properties.
+
+### SDM Thermostat Channels
+
+| Channel Type ID | Item Type | Description | Read Write |
+|---------------------|----------------------|------------------------------------------------------------------------|:----------:|
+| ambient_humidity | Number:Dimensionless | Lists the current ambient humidity percentage from the thermostat | R |
+| ambient_temperature | Number:Temperature | Lists the current ambient temperature from the thermostat | R |
+| current_eco_mode | String | Lists the current eco mode from the thermostat (OFF, MANUAL_ECO) | R/W |
+| current_mode | String | Lists the current mode from the thermostat (OFF, HEAT, COOL, HEATCOOL) | R/W |
+| fan_timer_mode | Switch | Lists the current fan timer mode | R/W |
+| fan_timer_timeout | DateTime | Timestamp at which timer mode turns OFF | R/W |
+| hvac_status | String | Provides the thermostat HVAC Status (OFF, HEATING, COOLING) | R |
+| maximum_temperature | Number:Temperature | Lists the maximum temperature setting from the thermostat | R/W |
+| minimum_temperature | Number:Temperature | Lists the target temperature setting from the thermostat | R/W |
+| target_temperature | Number:Temperature | Lists the target temperature setting from the thermostat | R/W |
+| temperature_cool | Number:Temperature | Lists the heat temperature Setting from the thermostat | R |
+| temperature_heat | Number:Temperature | Lists the heat temperature setting from the thermostat | R |
+
+The `fan_timer_mode` channel has a `fanTimerDuration` configuration parameter that can be used for configuring how long the fan is ON before it is switched OFF (1s to 43200s).
+Similarly, when a DateTime command is sent to the `fan_timer_timeout` channel, the fan timer is switched ON and runs until the timestamp in the command (min now+1s, max now+43200s).
+
+### WWN Camera Channels
**Camera group channels**
| urls_expire_time | DateTime | Timestamp when the camera event URLs expire | R |
| web_url | String | The web URL for the camera event, allows you to see the camera event in a web page | R |
-### Smoke Detector Channels
+### WWN Smoke Detector Channels
| Channel Type ID | Item Type | Description | Read Write |
|-----------------------|-----------|-----------------------------------------------------------------------------------|:----------:|
| smoke_alarm_state | String | The smoke alarm state of the Nest Protect (OK, EMERGENCY, WARNING) | R |
| ui_color_state | String | The current color of the ring on the smoke detector (GRAY, GREEN, YELLOW, RED) | R |
-### Structure Channels
+### WWN Structure Channels
| Channel Type ID | Item Type | Description | Read Write |
|------------------------------|-----------|--------------------------------------------------------------------------------------------------------|:----------:|
| smoke_alarm_state | String | Smoke alarm state (OK, EMERGENCY, WARNING) | R |
| time_zone | String | The time zone for the structure ([IANA time zone format](https://www.iana.org/time-zones)) | R |
-### Thermostat Channels
+### WWN Thermostat Channels
| Channel Type ID | Item Type | Description | Read Write |
|-----------------------------|----------------------|----------------------------------------------------------------------------------------|:----------:|
You can use the discovery functionality of the binding to obtain the deviceId and structureId values for defining Nest things in files.
-Another way to get the deviceId and structureId values is by querying the Nest API yourself. First [obtain an Access Token](https://developers.nest.com/documentation/cloud/sample-code-auth) (or use the Access Token obtained by the binding).
-Then use it with one of the [API Read Examples](https://developers.nest.com/documentation/cloud/how-to-read-data).
-
-### demo.things:
+### sdm-demo.things
```
-Bridge nest:account:demo_account [ productId="8fdf9885-ca07-4252-1aa3-f3d5ca9589e0", productSecret="QITLR3iyUlWaj9dbvCxsCKp4f", accessToken="c.6rse1xtRk2UANErcY0XazaqPHgbvSSB6owOrbZrZ6IXrmqhsr9QTmcfaiLX1l0ULvlI5xLp01xmKeiojHqozLQbNM8yfITj1LSdK28zsUft1aKKH2mDlOeoqZKBdVIsxyZk4orH0AvKEZ5aY" ] {
- camera fish_cam [ deviceId="qw0NNE8ruxA9AGJkTaFH3KeUiJaONWKiH9Gh3RwwhHClonIexTtufQ" ]
- smoke_detector hallway_smoke [ deviceId="Tzvibaa3lLKnHpvpi9OQeCI_z5rfkBAV" ]
- structure home [ structureId="20wKjydArmMV3kOluTA7JRcZg8HKBzTR-G_2nRXuIN1Bd6laGLOJQw" ]
- thermostat living_thermostat [ deviceId="ZqAKzSv6TO6PjBnOCXf9LSI_z5rfkBAV" ]
+Bridge nest:sdm_account:demo_sdm_account [ sdmProjectId="585de72e-968c-435c-b16a-31d1d3f76833", sdmClientId="1046297811237-3f5sj4ccfubit0fum027ral82jgffsd1.apps.googleusercontent.com", sdmClientSecret="726kcU-d1W4RXxEJA79oZ0oG", sdmAuthorizationCode="xkkY3qYtfZCzaXCcPxpOELUW8EhgiSMD3n9jmzJ3m0yerkQpVRdj5vqWRjMSIG", pubsubProjectId="openhab-12345", pubsubSubscriptionId="nest-sdm-events", pubsubClientId="1046297811237-lg27h26kln6r1nbg54jpg6nfjg6h4b3n.apps.googleusercontent.com", pubsubClientSecret="1-k78-XcHhp_gdZF-I6JaIHp", pubsubAuthorizationCode="tASfQq7gn6sfbUSbwRufbMI0BYDzh1d7MBG2G7vdZpbhjmZfwDp5MkeaX0iMxn" ] {
+ Thing sdm_camera fish_cam [ deviceId="AVPHwTQCAhersqmQ3IXwyqSX-XyuVZXoiNSNPeHdIMKgYpYZolNP4S9LS5QDF2LeuM3BQcpBh_fOEZYxkeH6eoQdWEELqi" ] {
+ Channels:
+ Image : motion_event#image [ imageHeight=1080 ]
+ Image : person_event#image [ imageWidth=1920 ]
+ Image : sound_event#image [ imageHeight=1080 ]
+ }
+ Thing sdm_doorbell front_door [ deviceId="AVPHws4JWeIzZlru3DSxXoKnIgPntKpzax7a1Zwms8H0-HaRet2pTdTCPOTBZ74YDzYqq7w6XpEPwOTkBXtf4KCJ4nq9hq" ] {
+ Channels:
+ Image : chime_event#image [ imageWidth=1920 ]
+ }
+ Thing sdm_display kitchen_hub [ deviceId="AVPHw64dWG5CcAJdDNzBbHWgu91l4v8WA4CsJqgtrvMS3QrbDnurB0_WzZEwpcWaw8Y9rLEQXW0avEwCjTd40Gmia6ussU" ]
+ Thing sdm_thermostat living_thermostat [ deviceId="AVPHwQum_bx9LmiRfv6jv5qPcKho0vHx2HqqMUvXP3TD-TTDCJebbzkegpRMozU5t7GSeTQIzxdH2LYDsZO8RClcGj7CCT", refreshInterval=180 ] {
+ Channels:
+ Image : fan_timer_mode [ fanTimerDuration=7200 ]
+ }
}
```
-### demo.items:
+### sdm-demo.items
+
+```
+/* SDM Doorbell */
+Image Doorbell_Chime_Image "Chime Image" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:chime_event#image" }
+DateTime Doorbell_Chime_Timestamp "Chime Timestamp [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:chime_event#timestamp" }
+String Doorbell_Stream_Token "Stream Token [%s]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:live_stream#current_token" }
+DateTime Doorbell_Stream_Timestamp "Stream Timestamp [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:live_stream#expiration_timestamp" }
+String Doorbell_Stream_Ext_Token "Stream Extension Token [%s]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:live_stream#extension_token" }
+String Doorbell_Stream_URL "Stream Extension URL [%s]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:live_stream#url" }
+Image Doorbell_Motion_Image "Motion Image" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:motion_event#image" }
+DateTime Doorbell_Motion_Timestamp "Motion Timestamp [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:motion_event#timestamp" }
+Image Doorbell_Person_Image "Person Image" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:person_event#image" }
+DateTime Doorbell_Person_Timestamp "Person Timestamp [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:person_event#timestamp" }
+Image Doorbell_Sound_Image "Sound Image" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:sound_event#image" }
+DateTime Doorbell_Sound_Timestamp "Sound Timestamp [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:sdm_doorbell:demo_sdm_account:front_door:sound_event#timestamp" }
+
+/* SDM Thermostat */
+Number:Dimensionless Thermostat_Amb_Humidity "Ambient Humidity [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:ambient_humidity" }
+Number:Temperature Thermostat_Amb_Temperature "Ambient Temperature [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:ambient_temperature" }
+String Thermostat_Current_Eco_Mode "Current Eco Mode [%s]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:current_eco_mode" }
+String Thermostat_Current_Mode "Current Mode [%s]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:current_mode" }
+Switch Thermostat_Fan_Timer_Mode "Fan Timer Mode" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:fan_timer_mode" }
+DateTime Thermostat_Fan_Timer_Timeout "Fan Timer Timeout [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:fan_timer_timeout" }
+String Thermostat_HVAC_Status "HVAC Status [%s]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:hvac_status" }
+Number:Temperature Thermostat_Max_Temperature "Max Temperature [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:maximum_temperature" }
+Number:Temperature Thermostat_Min_Temperature "Min Temperature [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:minimum_temperature" }
+Number:Temperature Thermostat_Target_temperature "Target Temperature [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:target_temperature" }
+Number:Temperature Thermostat_Temperature_Cool "Temperature Cool [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:temperature_cool" }
+Number:Temperature Thermostat_Temperature_Heat "Temperature Heat [%.1f %unit%]" { channel="nest:sdm_thermostat:demo_sdm_account:living_thermostat:temperature_heat" }
+```
+
+### wwn-demo.things
+
+```
+Bridge nest:wwn_account:demo_wwn_account [ productId="8fdf9885-ca07-4252-1aa3-f3d5ca9589e0", productSecret="QITLR3iyUlWaj9dbvCxsCKp4f", accessToken="c.6rse1xtRk2UANErcY0XazaqPHgbvSSB6owOrbZrZ6IXrmqhsr9QTmcfaiLX1l0ULvlI5xLp01xmKeiojHqozLQbNM8yfITj1LSdK28zsUft1aKKH2mDlOeoqZKBdVIsxyZk4orH0AvKEZ5aY" ] {
+ Thing wwn_camera fish_cam [ deviceId="qw0NNE8ruxA9AGJkTaFH3KeUiJaONWKiH9Gh3RwwhHClonIexTtufQ" ]
+ Thing wwn_smoke_detector hallway_smoke [ deviceId="Tzvibaa3lLKnHpvpi9OQeCI_z5rfkBAV" ]
+ Thing wwn_structure home [ structureId="20wKjydArmMV3kOluTA7JRcZg8HKBzTR-G_2nRXuIN1Bd6laGLOJQw" ]
+ Thing wwn_thermostat living_thermostat [ deviceId="ZqAKzSv6TO6PjBnOCXf9LSI_z5rfkBAV" ]
+}
+```
+### wwn-demo.items
```
-/* Camera */
-String Cam_App_URL "App URL [%s]" { channel="nest:camera:demo_account:fish_cam:camera#app_url" }
-Switch Cam_Audio_Input_Enabled "Audio Input Enabled" { channel="nest:camera:demo_account:fish_cam:camera#audio_input_enabled" }
-DateTime Cam_Last_Online_Change "Last Online Change [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:camera:demo_account:fish_cam:camera#last_online_change" }
-String Cam_Snapshot_URL "Snapshot URL [%s]" { channel="nest:camera:demo_account:fish_cam:camera#snapshot_url" }
-Switch Cam_Streaming "Streaming" { channel="nest:camera:demo_account:fish_cam:camera#streaming" }
-Switch Cam_Public_Share_Enabled "Public Share Enabled" { channel="nest:camera:demo_account:fish_cam:camera#public_share_enabled" }
-String Cam_Public_Share_URL "Public Share URL [%s]" { channel="nest:camera:demo_account:fish_cam:camera#public_share_url" }
-Switch Cam_Video_History_Enabled "Video History Enabled" { channel="nest:camera:demo_account:fish_cam:camera#video_history_enabled" }
-String Cam_Web_URL "Web URL [%s]" { channel="nest:camera:demo_account:fish_cam:camera#web_url" }
-String Cam_LE_Activity_Zones "Last Event Activity Zones [%s]" { channel="nest:camera:demo_account:fish_cam:last_event#activity_zones" }
-String Cam_LE_Animated_Image_URL "Last Event Animated Image URL [%s]" { channel="nest:camera:demo_account:fish_cam:last_event#animated_image_url" }
-String Cam_LE_App_URL "Last Event App URL [%s]" { channel="nest:camera:demo_account:fish_cam:last_event#app_url" }
-DateTime Cam_LE_End_Time "Last Event End Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:camera:demo_account:fish_cam:last_event#end_time" }
-Switch Cam_LE_Has_Motion "Last Event Has Motion" { channel="nest:camera:demo_account:fish_cam:last_event#has_motion" }
-Switch Cam_LE_Has_Person "Last Event Has Person" { channel="nest:camera:demo_account:fish_cam:last_event#has_person" }
-Switch Cam_LE_Has_Sound "Last Event Has Sound" { channel="nest:camera:demo_account:fish_cam:last_event#has_sound" }
-String Cam_LE_Image_URL "Last Event Image URL [%s]" { channel="nest:camera:demo_account:fish_cam:last_event#image_url" }
-DateTime Cam_LE_Start_Time "Last Event Start Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:camera:demo_account:fish_cam:last_event#start_time" }
-DateTime Cam_LE_URLs_Expire_Time "Last Event URLs Expire Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:camera:demo_account:fish_cam:last_event#urls_expire_time" }
-String Cam_LE_Web_URL "Last Event Web URL [%s]" { channel="nest:camera:demo_account:fish_cam:last_event#web_url" }
-
-/* Smoke Detector */
-String Smoke_CO_Alarm "CO Alarm [%s]" { channel="nest:smoke_detector:demo_account:hallway_smoke:co_alarm_state" }
-Switch Smoke_Battery_Low "Battery Low" { channel="nest:smoke_detector:demo_account:hallway_smoke:low_battery" }
-Switch Smoke_Manual_Test "Manual Test" { channel="nest:smoke_detector:demo_account:hallway_smoke:manual_test_active" }
-DateTime Smoke_Last_Connection "Last Connection [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:smoke_detector:demo_account:hallway_smoke:last_connection" }
-DateTime Smoke_Last_Manual_Test "Last Manual Test [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:smoke_detector:demo_account:hallway_smoke:last_manual_test_time" }
-String Smoke_Smoke_Alarm "Smoke Alarm [%s]" { channel="nest:smoke_detector:demo_account:hallway_smoke:smoke_alarm_state" }
-String Smoke_UI_Color "UI Color [%s]" { channel="nest:smoke_detector:demo_account:hallway_smoke:ui_color_state" }
-
-/* Thermostat */
-Switch Thermostat_Can_Cool "Can Cool" { channel="nest:thermostat:demo_account:living_thermostat:can_cool" }
-Switch Thermostat_Can_Heat "Can Heat" { channel="nest:thermostat:demo_account:living_thermostat:can_heat" }
-Number:Temperature Therm_EMaxSP "Eco Max Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:eco_max_set_point" }
-Number:Temperature Therm_EMinSP "Eco Min Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:eco_min_set_point" }
-Switch Thermostat_FT_Active "Fan Timer Active" { channel="nest:thermostat:demo_account:living_thermostat:fan_timer_active" }
-Number:Time Thermostat_FT_Duration "Fan Timer Duration [%d %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:fan_timer_duration" }
-DateTime Thermostat_FT_Timeout "Fan Timer Timeout [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:thermostat:demo_account:living_thermostat:fan_timer_timeout" }
-Switch Thermostat_Has_Fan "Has Fan" { channel="nest:thermostat:demo_account:living_thermostat:has_fan" }
-Switch Thermostat_Has_Leaf "Has Leaf" { channel="nest:thermostat:demo_account:living_thermostat:has_leaf" }
-Number:Dimensionless Therm_Hum "Humidity [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:humidity" }
-DateTime Thermostat_Last_Conn "Last Connection [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:thermostat:demo_account:living_thermostat:last_connection" }
-Switch Thermostat_Locked "Locked" { channel="nest:thermostat:demo_account:living_thermostat:locked" }
-Number:Temperature Therm_LMaxSP "Locked Max Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:locked_max_set_point" }
-Number:Temperature Therm_LMinSP "Locked Min Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:locked_min_set_point" }
-Number:Temperature Therm_Max_SP "Max Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:max_set_point" }
-Number:Temperature Therm_Min_SP "Min Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:min_set_point" }
-String Thermostat_Mode "Mode [%s]" { channel="nest:thermostat:demo_account:living_thermostat:mode" }
-String Thermostat_Previous_Mode "Previous Mode [%s]" { channel="nest:thermostat:demo_account:living_thermostat:previous_mode" }
-String Thermostat_State "State [%s]" { channel="nest:thermostat:demo_account:living_thermostat:state" }
-Number:Temperature Thermostat_SP "Set Point [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:set_point" }
-Switch Thermostat_Sunlight_CA "Sunlight Correction Active" { channel="nest:thermostat:demo_account:living_thermostat:sunlight_correction_active" }
-Switch Thermostat_Sunlight_CE "Sunlight Correction Enabled" { channel="nest:thermostat:demo_account:living_thermostat:sunlight_correction_enabled" }
-Number:Temperature Therm_Temp "Temperature [%.1f %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:temperature" }
-Number:Time Therm_Time_To_Target "Time To Target [%d %unit%]" { channel="nest:thermostat:demo_account:living_thermostat:time_to_target" }
-Switch Thermostat_Using_Em_Heat "Using Emergency Heat" { channel="nest:thermostat:demo_account:living_thermostat:using_emergency_heat" }
-
-/* Structure */
-String Home_Away "Away [%s]" { channel="nest:structure:demo_account:home:away" }
-String Home_Country_Code "Country Code [%s]" { channel="nest:structure:demo_account:home:country_code" }
-String Home_CO_Alarm_State "CO Alarm State [%s]" { channel="nest:structure:demo_account:home:co_alarm_state" }
-DateTime Home_ETA "ETA [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:structure:demo_account:home:eta_begin" }
-DateTime Home_PP_End_Time "PP End Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:structure:demo_account:home:peak_period_end_time" }
-DateTime Home_PP_Start_Time "PP Start Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:structure:demo_account:home:peak_period_start_time" }
-String Home_Postal_Code "Postal Code [%s]" { channel="nest:structure:demo_account:home:postal_code" }
-Switch Home_Rush_Hour_Rewards "Rush Hour Rewards" { channel="nest:structure:demo_account:home:rush_hour_rewards_enrollment" }
-String Home_Security_State "Security State [%s]" { channel="nest:structure:demo_account:home:security_state" }
-String Home_Smoke_Alarm_State "Smoke Alarm State [%s]" { channel="nest:structure:demo_account:home:smoke_alarm_state" }
-String Home_Time_Zone "Time Zone [%s]" { channel="nest:structure:demo_account:home:time_zone" }
+/* WWN Camera */
+String Cam_App_URL "App URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#app_url" }
+Switch Cam_Audio_Input_Enabled "Audio Input Enabled" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#audio_input_enabled" }
+DateTime Cam_Last_Online_Change "Last Online Change [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#last_online_change" }
+String Cam_Snapshot_URL "Snapshot URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#snapshot_url" }
+Switch Cam_Streaming "Streaming" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#streaming" }
+Switch Cam_Public_Share_Enabled "Public Share Enabled" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#public_share_enabled" }
+String Cam_Public_Share_URL "Public Share URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#public_share_url" }
+Switch Cam_Video_History_Enabled "Video History Enabled" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#video_history_enabled" }
+String Cam_Web_URL "Web URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:camera#web_url" }
+String Cam_LE_Activity_Zones "Last Event Activity Zones [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#activity_zones" }
+String Cam_LE_Animated_Image_URL "Last Event Animated Image URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#animated_image_url" }
+String Cam_LE_App_URL "Last Event App URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#app_url" }
+DateTime Cam_LE_End_Time "Last Event End Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#end_time" }
+Switch Cam_LE_Has_Motion "Last Event Has Motion" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#has_motion" }
+Switch Cam_LE_Has_Person "Last Event Has Person" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#has_person" }
+Switch Cam_LE_Has_Sound "Last Event Has Sound" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#has_sound" }
+String Cam_LE_Image_URL "Last Event Image URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#image_url" }
+DateTime Cam_LE_Start_Time "Last Event Start Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#start_time" }
+DateTime Cam_LE_URLs_Expire_Time "Last Event URLs Expire Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#urls_expire_time" }
+String Cam_LE_Web_URL "Last Event Web URL [%s]" { channel="nest:wwn_camera:demo_wwn_account:fish_cam:last_event#web_url" }
+
+/* WWN Smoke Detector */
+String Smoke_CO_Alarm "CO Alarm [%s]" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:co_alarm_state" }
+Switch Smoke_Battery_Low "Battery Low" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:low_battery" }
+Switch Smoke_Manual_Test "Manual Test" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:manual_test_active" }
+DateTime Smoke_Last_Connection "Last Connection [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:last_connection" }
+DateTime Smoke_Last_Manual_Test "Last Manual Test [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:last_manual_test_time" }
+String Smoke_Smoke_Alarm "Smoke Alarm [%s]" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:smoke_alarm_state" }
+String Smoke_UI_Color "UI Color [%s]" { channel="nest:wwn_smoke_detector:demo_wwn_account:hallway_smoke:ui_color_state" }
+
+/* WWN Thermostat */
+Switch Thermostat_Can_Cool "Can Cool" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:can_cool" }
+Switch Thermostat_Can_Heat "Can Heat" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:can_heat" }
+Number:Temperature Therm_EMaxSP "Eco Max Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:eco_max_set_point" }
+Number:Temperature Therm_EMinSP "Eco Min Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:eco_min_set_point" }
+Switch Thermostat_FT_Active "Fan Timer Active" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:fan_timer_active" }
+Number:Time Thermostat_FT_Duration "Fan Timer Duration [%d %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:fan_timer_duration" }
+DateTime Thermostat_FT_Timeout "Fan Timer Timeout [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:fan_timer_timeout" }
+Switch Thermostat_Has_Fan "Has Fan" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:has_fan" }
+Switch Thermostat_Has_Leaf "Has Leaf" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:has_leaf" }
+Number:Dimensionless Therm_Hum "Humidity [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:humidity" }
+DateTime Thermostat_Last_Conn "Last Connection [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:last_connection" }
+Switch Thermostat_Locked "Locked" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:locked" }
+Number:Temperature Therm_LMaxSP "Locked Max Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:locked_max_set_point" }
+Number:Temperature Therm_LMinSP "Locked Min Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:locked_min_set_point" }
+Number:Temperature Therm_Max_SP "Max Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:max_set_point" }
+Number:Temperature Therm_Min_SP "Min Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:min_set_point" }
+String Thermostat_Mode "Mode [%s]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:mode" }
+String Thermostat_Previous_Mode "Previous Mode [%s]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:previous_mode" }
+String Thermostat_State "State [%s]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:state" }
+Number:Temperature Thermostat_SP "Set Point [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:set_point" }
+Switch Thermostat_Sunlight_CA "Sunlight Correction Active" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:sunlight_correction_active" }
+Switch Thermostat_Sunlight_CE "Sunlight Correction Enabled" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:sunlight_correction_enabled" }
+Number:Temperature Therm_Temp "Temperature [%.1f %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:temperature" }
+Number:Time Therm_Time_To_Target "Time To Target [%d %unit%]" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:time_to_target" }
+Switch Thermostat_Using_Em_Heat "Using Emergency Heat" { channel="nest:wwn_thermostat:demo_wwn_account:living_thermostat:using_emergency_heat" }
+
+/* WWN Structure */
+String Home_Away "Away [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:away" }
+String Home_Country_Code "Country Code [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:country_code" }
+String Home_CO_Alarm_State "CO Alarm State [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:co_alarm_state" }
+DateTime Home_ETA "ETA [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_structure:demo_wwn_account:home:eta_begin" }
+DateTime Home_PP_End_Time "PP End Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_structure:demo_wwn_account:home:peak_period_end_time" }
+DateTime Home_PP_Start_Time "PP Start Time [%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS]" { channel="nest:wwn_structure:demo_wwn_account:home:peak_period_start_time" }
+String Home_Postal_Code "Postal Code [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:postal_code" }
+Switch Home_Rush_Hour_Rewards "Rush Hour Rewards" { channel="nest:wwn_structure:demo_wwn_account:home:rush_hour_rewards_enrollment" }
+String Home_Security_State "Security State [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:security_state" }
+String Home_Smoke_Alarm_State "Smoke Alarm State [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:smoke_alarm_state" }
+String Home_Time_Zone "Time Zone [%s]" { channel="nest:wwn_structure:demo_wwn_account:home:time_zone" }
```
## Attribution
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal;
-
-import java.time.Duration;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.core.thing.ThingTypeUID;
-
-/**
- * The {@link NestBindingConstants} class defines common constants, which are
- * used across the whole binding.
- *
- * @author David Bennett - Initial contribution
- */
-@NonNullByDefault
-public class NestBindingConstants {
-
- public static final String BINDING_ID = "nest";
-
- /** The URL to use to connect to Nest with. */
- public static final String NEST_URL = "https://developer-api.nest.com";
-
- /** The URL to get the access token when talking to Nest. */
- public static final String NEST_ACCESS_TOKEN_URL = "https://api.home.nest.com/oauth2/access_token";
-
- /** The path to set values on the thermostat when talking to Nest. */
- public static final String NEST_THERMOSTAT_UPDATE_PATH = "/devices/thermostats/";
-
- /** The path to set values on the structure when talking to Nest. */
- public static final String NEST_STRUCTURE_UPDATE_PATH = "/structures/";
-
- /** The path to set values on the camera when talking to Nest. */
- public static final String NEST_CAMERA_UPDATE_PATH = "/devices/cameras/";
-
- /** The path to set values on the camera when talking to Nest. */
- public static final String NEST_SMOKE_ALARM_UPDATE_PATH = "/devices/smoke_co_alarms/";
-
- /** The JSON content type used when talking to Nest. */
- public static final String JSON_CONTENT_TYPE = "application/json";
-
- /** To keep the streaming REST connection alive Nest sends every 30 seconds a message. */
- public static final long KEEP_ALIVE_MILLIS = Duration.ofSeconds(30).toMillis();
-
- /** To avoid API throttling errors (429 Too Many Requests) Nest recommends making at most one call per minute. */
- public static final int MIN_SECONDS_BETWEEN_API_CALLS = 60;
-
- // List of all Thing Type UIDs
- public static final ThingTypeUID THING_TYPE_THERMOSTAT = new ThingTypeUID(BINDING_ID, "thermostat");
- public static final ThingTypeUID THING_TYPE_CAMERA = new ThingTypeUID(BINDING_ID, "camera");
- public static final ThingTypeUID THING_TYPE_SMOKE_DETECTOR = new ThingTypeUID(BINDING_ID, "smoke_detector");
- public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "account");
- public static final ThingTypeUID THING_TYPE_STRUCTURE = new ThingTypeUID(BINDING_ID, "structure");
-
- // List of all channel group prefixes
- public static final String CHANNEL_GROUP_CAMERA_PREFIX = "camera#";
- public static final String CHANNEL_GROUP_LAST_EVENT_PREFIX = "last_event#";
-
- // List of all Channel IDs
- // read only channels (common)
- public static final String CHANNEL_LAST_CONNECTION = "last_connection";
-
- // read/write channels (thermostat)
- public static final String CHANNEL_MODE = "mode";
- public static final String CHANNEL_SET_POINT = "set_point";
- public static final String CHANNEL_MAX_SET_POINT = "max_set_point";
- public static final String CHANNEL_MIN_SET_POINT = "min_set_point";
- public static final String CHANNEL_FAN_TIMER_ACTIVE = "fan_timer_active";
- public static final String CHANNEL_FAN_TIMER_DURATION = "fan_timer_duration";
-
- // read only channels (thermostat)
- public static final String CHANNEL_ECO_MAX_SET_POINT = "eco_max_set_point";
- public static final String CHANNEL_ECO_MIN_SET_POINT = "eco_min_set_point";
- public static final String CHANNEL_LOCKED = "locked";
- public static final String CHANNEL_LOCKED_MAX_SET_POINT = "locked_max_set_point";
- public static final String CHANNEL_LOCKED_MIN_SET_POINT = "locked_min_set_point";
- public static final String CHANNEL_TEMPERATURE = "temperature";
- public static final String CHANNEL_HUMIDITY = "humidity";
- public static final String CHANNEL_PREVIOUS_MODE = "previous_mode";
- public static final String CHANNEL_STATE = "state";
- public static final String CHANNEL_CAN_HEAT = "can_heat";
- public static final String CHANNEL_CAN_COOL = "can_cool";
- public static final String CHANNEL_FAN_TIMER_TIMEOUT = "fan_timer_timeout";
- public static final String CHANNEL_HAS_FAN = "has_fan";
- public static final String CHANNEL_HAS_LEAF = "has_leaf";
- public static final String CHANNEL_SUNLIGHT_CORRECTION_ENABLED = "sunlight_correction_enabled";
- public static final String CHANNEL_SUNLIGHT_CORRECTION_ACTIVE = "sunlight_correction_active";
- public static final String CHANNEL_TIME_TO_TARGET = "time_to_target";
- public static final String CHANNEL_USING_EMERGENCY_HEAT = "using_emergency_heat";
-
- // read/write channels (camera)
- public static final String CHANNEL_CAMERA_STREAMING = "camera#streaming";
-
- // read only channels (camera)
- public static final String CHANNEL_CAMERA_AUDIO_INPUT_ENABLED = "camera#audio_input_enabled";
- public static final String CHANNEL_CAMERA_VIDEO_HISTORY_ENABLED = "camera#video_history_enabled";
- public static final String CHANNEL_CAMERA_WEB_URL = "camera#web_url";
- public static final String CHANNEL_CAMERA_APP_URL = "camera#app_url";
- public static final String CHANNEL_CAMERA_PUBLIC_SHARE_ENABLED = "camera#public_share_enabled";
- public static final String CHANNEL_CAMERA_PUBLIC_SHARE_URL = "camera#public_share_url";
- public static final String CHANNEL_CAMERA_SNAPSHOT_URL = "camera#snapshot_url";
- public static final String CHANNEL_CAMERA_LAST_ONLINE_CHANGE = "camera#last_online_change";
-
- public static final String CHANNEL_LAST_EVENT_HAS_SOUND = "last_event#has_sound";
- public static final String CHANNEL_LAST_EVENT_HAS_MOTION = "last_event#has_motion";
- public static final String CHANNEL_LAST_EVENT_HAS_PERSON = "last_event#has_person";
- public static final String CHANNEL_LAST_EVENT_START_TIME = "last_event#start_time";
- public static final String CHANNEL_LAST_EVENT_END_TIME = "last_event#end_time";
- public static final String CHANNEL_LAST_EVENT_URLS_EXPIRE_TIME = "last_event#urls_expire_time";
- public static final String CHANNEL_LAST_EVENT_WEB_URL = "last_event#web_url";
- public static final String CHANNEL_LAST_EVENT_APP_URL = "last_event#app_url";
- public static final String CHANNEL_LAST_EVENT_IMAGE_URL = "last_event#image_url";
- public static final String CHANNEL_LAST_EVENT_ANIMATED_IMAGE_URL = "last_event#animated_image_url";
- public static final String CHANNEL_LAST_EVENT_ACTIVITY_ZONES = "last_event#activity_zones";
-
- // read/write channels (smoke detector)
-
- // read only channels (smoke detector)
- public static final String CHANNEL_UI_COLOR_STATE = "ui_color_state";
- public static final String CHANNEL_LOW_BATTERY = "low_battery";
- public static final String CHANNEL_CO_ALARM_STATE = "co_alarm_state"; // Also in structure
- public static final String CHANNEL_SMOKE_ALARM_STATE = "smoke_alarm_state"; // Also in structure
- public static final String CHANNEL_MANUAL_TEST_ACTIVE = "manual_test_active";
- public static final String CHANNEL_LAST_MANUAL_TEST_TIME = "last_manual_test_time";
-
- // read/write channel (structure)
- public static final String CHANNEL_AWAY = "away";
-
- // read only channels (structure)
- public static final String CHANNEL_COUNTRY_CODE = "country_code";
- public static final String CHANNEL_POSTAL_CODE = "postal_code";
- public static final String CHANNEL_PEAK_PERIOD_START_TIME = "peak_period_start_time";
- public static final String CHANNEL_PEAK_PERIOD_END_TIME = "peak_period_end_time";
- public static final String CHANNEL_TIME_ZONE = "time_zone";
- public static final String CHANNEL_ETA_BEGIN = "eta_begin";
- public static final String CHANNEL_RUSH_HOUR_REWARDS_ENROLLMENT = "rush_hour_rewards_enrollment";
- public static final String CHANNEL_SECURITY_STATE = "security_state";
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal;
-
-import static java.util.stream.Collectors.toSet;
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-
-import java.util.HashMap;
-import java.util.Hashtable;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Stream;
-
-import javax.ws.rs.client.ClientBuilder;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.discovery.NestDiscoveryService;
-import org.openhab.binding.nest.internal.handler.NestBridgeHandler;
-import org.openhab.binding.nest.internal.handler.NestCameraHandler;
-import org.openhab.binding.nest.internal.handler.NestSmokeDetectorHandler;
-import org.openhab.binding.nest.internal.handler.NestStructureHandler;
-import org.openhab.binding.nest.internal.handler.NestThermostatHandler;
-import org.openhab.core.config.discovery.DiscoveryService;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingTypeUID;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.BaseThingHandlerFactory;
-import org.openhab.core.thing.binding.ThingHandler;
-import org.openhab.core.thing.binding.ThingHandlerFactory;
-import org.osgi.framework.ServiceRegistration;
-import org.osgi.service.component.annotations.Activate;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-
-/**
- * The {@link NestHandlerFactory} is responsible for creating things and thing
- * handlers. It also sets up the discovery service to track things from the bridge
- * when the bridge is created.
- *
- * @author David Bennett - Initial contribution
- */
-@NonNullByDefault
-@Component(service = ThingHandlerFactory.class, configurationPid = "binding.nest")
-public class NestHandlerFactory extends BaseThingHandlerFactory {
- private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Stream.of(THING_TYPE_THERMOSTAT,
- THING_TYPE_CAMERA, THING_TYPE_BRIDGE, THING_TYPE_STRUCTURE, THING_TYPE_SMOKE_DETECTOR).collect(toSet());
-
- private final ClientBuilder clientBuilder;
- private final SseEventSourceFactory eventSourceFactory;
- private final Map<ThingUID, ServiceRegistration<?>> discoveryService = new HashMap<>();
-
- @Activate
- public NestHandlerFactory(@Reference ClientBuilder clientBuilder,
- @Reference SseEventSourceFactory eventSourceFactory) {
- this.clientBuilder = clientBuilder;
- this.eventSourceFactory = eventSourceFactory;
- }
-
- /**
- * The things this factory supports creating.
- */
- @Override
- public boolean supportsThingType(ThingTypeUID thingTypeUID) {
- return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
- }
-
- /**
- * Creates a handler for the specific thing. THis also creates the discovery service
- * when the bridge is created.
- */
- @Override
- protected @Nullable ThingHandler createHandler(Thing thing) {
- ThingTypeUID thingTypeUID = thing.getThingTypeUID();
-
- if (THING_TYPE_THERMOSTAT.equals(thingTypeUID)) {
- return new NestThermostatHandler(thing);
- }
-
- if (THING_TYPE_CAMERA.equals(thingTypeUID)) {
- return new NestCameraHandler(thing);
- }
-
- if (THING_TYPE_STRUCTURE.equals(thingTypeUID)) {
- return new NestStructureHandler(thing);
- }
-
- if (THING_TYPE_SMOKE_DETECTOR.equals(thingTypeUID)) {
- return new NestSmokeDetectorHandler(thing);
- }
-
- if (THING_TYPE_BRIDGE.equals(thingTypeUID)) {
- NestBridgeHandler handler = new NestBridgeHandler((Bridge) thing, clientBuilder, eventSourceFactory);
- NestDiscoveryService service = new NestDiscoveryService(handler);
- service.activate();
- // Register the discovery service.
- discoveryService.put(handler.getThing().getUID(),
- bundleContext.registerService(DiscoveryService.class.getName(), service, new Hashtable<>()));
- return handler;
- }
-
- return null;
- }
-
- /**
- * Removes the handler for the specific thing. This also handles disabling the discovery
- * service when the bridge is removed.
- */
- @Override
- protected void removeHandler(ThingHandler thingHandler) {
- if (thingHandler instanceof NestBridgeHandler) {
- ServiceRegistration<?> reg = discoveryService.get(thingHandler.getThing().getUID());
- if (reg != null) {
- // Unregister the discovery service.
- NestDiscoveryService service = (NestDiscoveryService) bundleContext.getService(reg.getReference());
- service.deactivate();
- reg.unregister();
- discoveryService.remove(thingHandler.getThing().getUID());
- }
- }
- super.removeHandler(thingHandler);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal;
-
-import java.io.Reader;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-import com.google.gson.FieldNamingPolicy;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-
-/**
- * Utility class for sharing utility methods between objects.
- *
- * @author Wouter Born - Initial contribution
- */
-@NonNullByDefault
-public final class NestUtils {
-
- private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
- .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
-
- private NestUtils() {
- // hidden utility class constructor
- }
-
- public static <T> T fromJson(String json, Class<T> dataClass) {
- return GSON.fromJson(json, dataClass);
- }
-
- public static <T> T fromJson(Reader reader, Class<T> dataClass) {
- return GSON.fromJson(reader, dataClass);
- }
-
- public static String toJson(Object object) {
- return GSON.toJson(object);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.config;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- * The configuration for the Nest bridge, allowing it to talk to Nest.
- *
- * @author David Bennett - Initial contribution
- */
-@NonNullByDefault
-public class NestBridgeConfiguration {
- public static final String PRODUCT_ID = "productId";
- /** Product ID from the Nest product page. */
- public String productId = "";
-
- public static final String PRODUCT_SECRET = "productSecret";
- /** Product secret from the Nest product page. */
- public String productSecret = "";
-
- public static final String PINCODE = "pincode";
- /** Product pincode from the Nest authorization page. */
- public @Nullable String pincode;
-
- public static final String ACCESS_TOKEN = "accessToken";
- /** The access token to use once retrieved from Nest. */
- public @Nullable String accessToken;
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.config;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * The configuration for Nest devices.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Add device configuration to allow file based configuration
- */
-@NonNullByDefault
-public class NestDeviceConfiguration {
- public static final String DEVICE_ID = "deviceId";
- /** Device ID which can be retrieved with the Nest API. */
- public String deviceId = "";
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.config;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * The configuration for structures.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Add device configuration to allow file based configuration
- */
-@NonNullByDefault
-public class NestStructureConfiguration {
- public static final String STRUCTURE_ID = "structureId";
- /** Structure ID which can be retrieved with the Nest API. */
- public String structureId = "";
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * Deals with the access token data that comes back from Nest when it is requested.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class AccessTokenData {
-
- private String accessToken;
- private Long expiresIn;
-
- public String getAccessToken() {
- return accessToken;
- }
-
- public Long getExpiresIn() {
- return expiresIn;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- AccessTokenData other = (AccessTokenData) obj;
- if (accessToken == null) {
- if (other.accessToken != null) {
- return false;
- }
- } else if (!accessToken.equals(other.accessToken)) {
- return false;
- }
- if (expiresIn == null) {
- if (other.expiresIn != null) {
- return false;
- }
- } else if (!expiresIn.equals(other.expiresIn)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((accessToken == null) ? 0 : accessToken.hashCode());
- result = prime * result + ((expiresIn == null) ? 0 : expiresIn.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("AccessTokenData [accessToken=").append(accessToken).append(", expiresIn=").append(expiresIn)
- .append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * The data for a camera activity zone.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Extract ActivityZone object from Camera
- */
-public class ActivityZone {
-
- private String name;
- private int id;
-
- public String getName() {
- return name;
- }
-
- public int getId() {
- return id;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- ActivityZone other = (ActivityZone) obj;
- if (id != other.id) {
- return false;
- }
- if (name == null) {
- if (other.name != null) {
- return false;
- }
- } else if (!name.equals(other.name)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + id;
- result = prime * result + ((name == null) ? 0 : name.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("CameraActivityZone [name=").append(name).append(", id=").append(id).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Date;
-
-/**
- * Default properties shared across all Nest devices.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class BaseNestDevice implements NestIdentifiable {
-
- private String deviceId;
- private String name;
- private String nameLong;
- private Date lastConnection;
- private Boolean isOnline;
- private String softwareVersion;
- private String structureId;
-
- private String whereId;
-
- @Override
- public String getId() {
- return deviceId;
- }
-
- public String getName() {
- return name;
- }
-
- public String getDeviceId() {
- return deviceId;
- }
-
- public Date getLastConnection() {
- return lastConnection;
- }
-
- public Boolean isOnline() {
- return isOnline;
- }
-
- public String getNameLong() {
- return nameLong;
- }
-
- public String getSoftwareVersion() {
- return softwareVersion;
- }
-
- public String getStructureId() {
- return structureId;
- }
-
- public String getWhereId() {
- return whereId;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- BaseNestDevice other = (BaseNestDevice) obj;
- if (deviceId == null) {
- if (other.deviceId != null) {
- return false;
- }
- } else if (!deviceId.equals(other.deviceId)) {
- return false;
- }
- if (isOnline == null) {
- if (other.isOnline != null) {
- return false;
- }
- } else if (!isOnline.equals(other.isOnline)) {
- return false;
- }
- if (lastConnection == null) {
- if (other.lastConnection != null) {
- return false;
- }
- } else if (!lastConnection.equals(other.lastConnection)) {
- return false;
- }
- if (name == null) {
- if (other.name != null) {
- return false;
- }
- } else if (!name.equals(other.name)) {
- return false;
- }
- if (nameLong == null) {
- if (other.nameLong != null) {
- return false;
- }
- } else if (!nameLong.equals(other.nameLong)) {
- return false;
- }
- if (softwareVersion == null) {
- if (other.softwareVersion != null) {
- return false;
- }
- } else if (!softwareVersion.equals(other.softwareVersion)) {
- return false;
- }
- if (structureId == null) {
- if (other.structureId != null) {
- return false;
- }
- } else if (!structureId.equals(other.structureId)) {
- return false;
- }
- if (whereId == null) {
- if (other.whereId != null) {
- return false;
- }
- } else if (!whereId.equals(other.whereId)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((deviceId == null) ? 0 : deviceId.hashCode());
- result = prime * result + ((isOnline == null) ? 0 : isOnline.hashCode());
- result = prime * result + ((lastConnection == null) ? 0 : lastConnection.hashCode());
- result = prime * result + ((name == null) ? 0 : name.hashCode());
- result = prime * result + ((nameLong == null) ? 0 : nameLong.hashCode());
- result = prime * result + ((softwareVersion == null) ? 0 : softwareVersion.hashCode());
- result = prime * result + ((structureId == null) ? 0 : structureId.hashCode());
- result = prime * result + ((whereId == null) ? 0 : whereId.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("BaseNestDevice [deviceId=").append(deviceId).append(", name=").append(name)
- .append(", nameLong=").append(nameLong).append(", lastConnection=").append(lastConnection)
- .append(", isOnline=").append(isOnline).append(", softwareVersion=").append(softwareVersion)
- .append(", structureId=").append(structureId).append(", whereId=").append(whereId).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Date;
-import java.util.List;
-
-/**
- * The data for the camera.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class Camera extends BaseNestDevice {
-
- private Boolean isStreaming;
- private Boolean isAudioInputEnabled;
- private Date lastIsOnlineChange;
- private Boolean isVideoHistoryEnabled;
- private String webUrl;
- private String appUrl;
- private Boolean isPublicShareEnabled;
- private List<ActivityZone> activityZones;
- private String publicShareUrl;
- private String snapshotUrl;
- private CameraEvent lastEvent;
-
- public Boolean isStreaming() {
- return isStreaming;
- }
-
- public Boolean isAudioInputEnabled() {
- return isAudioInputEnabled;
- }
-
- public Date getLastIsOnlineChange() {
- return lastIsOnlineChange;
- }
-
- public Boolean isVideoHistoryEnabled() {
- return isVideoHistoryEnabled;
- }
-
- public String getWebUrl() {
- return webUrl;
- }
-
- public String getAppUrl() {
- return appUrl;
- }
-
- public Boolean isPublicShareEnabled() {
- return isPublicShareEnabled;
- }
-
- public List<ActivityZone> getActivityZones() {
- return activityZones;
- }
-
- public String getPublicShareUrl() {
- return publicShareUrl;
- }
-
- public String getSnapshotUrl() {
- return snapshotUrl;
- }
-
- public CameraEvent getLastEvent() {
- return lastEvent;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (!super.equals(obj)) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- Camera other = (Camera) obj;
- if (activityZones == null) {
- if (other.activityZones != null) {
- return false;
- }
- } else if (!activityZones.equals(other.activityZones)) {
- return false;
- }
- if (appUrl == null) {
- if (other.appUrl != null) {
- return false;
- }
- } else if (!appUrl.equals(other.appUrl)) {
- return false;
- }
- if (isAudioInputEnabled == null) {
- if (other.isAudioInputEnabled != null) {
- return false;
- }
- } else if (!isAudioInputEnabled.equals(other.isAudioInputEnabled)) {
- return false;
- }
- if (isPublicShareEnabled == null) {
- if (other.isPublicShareEnabled != null) {
- return false;
- }
- } else if (!isPublicShareEnabled.equals(other.isPublicShareEnabled)) {
- return false;
- }
- if (isStreaming == null) {
- if (other.isStreaming != null) {
- return false;
- }
- } else if (!isStreaming.equals(other.isStreaming)) {
- return false;
- }
- if (isVideoHistoryEnabled == null) {
- if (other.isVideoHistoryEnabled != null) {
- return false;
- }
- } else if (!isVideoHistoryEnabled.equals(other.isVideoHistoryEnabled)) {
- return false;
- }
- if (lastEvent == null) {
- if (other.lastEvent != null) {
- return false;
- }
- } else if (!lastEvent.equals(other.lastEvent)) {
- return false;
- }
- if (lastIsOnlineChange == null) {
- if (other.lastIsOnlineChange != null) {
- return false;
- }
- } else if (!lastIsOnlineChange.equals(other.lastIsOnlineChange)) {
- return false;
- }
- if (publicShareUrl == null) {
- if (other.publicShareUrl != null) {
- return false;
- }
- } else if (!publicShareUrl.equals(other.publicShareUrl)) {
- return false;
- }
- if (snapshotUrl == null) {
- if (other.snapshotUrl != null) {
- return false;
- }
- } else if (!snapshotUrl.equals(other.snapshotUrl)) {
- return false;
- }
- if (webUrl == null) {
- if (other.webUrl != null) {
- return false;
- }
- } else if (!webUrl.equals(other.webUrl)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = super.hashCode();
- result = prime * result + ((activityZones == null) ? 0 : activityZones.hashCode());
- result = prime * result + ((appUrl == null) ? 0 : appUrl.hashCode());
- result = prime * result + ((isAudioInputEnabled == null) ? 0 : isAudioInputEnabled.hashCode());
- result = prime * result + ((isPublicShareEnabled == null) ? 0 : isPublicShareEnabled.hashCode());
- result = prime * result + ((isStreaming == null) ? 0 : isStreaming.hashCode());
- result = prime * result + ((isVideoHistoryEnabled == null) ? 0 : isVideoHistoryEnabled.hashCode());
- result = prime * result + ((lastEvent == null) ? 0 : lastEvent.hashCode());
- result = prime * result + ((lastIsOnlineChange == null) ? 0 : lastIsOnlineChange.hashCode());
- result = prime * result + ((publicShareUrl == null) ? 0 : publicShareUrl.hashCode());
- result = prime * result + ((snapshotUrl == null) ? 0 : snapshotUrl.hashCode());
- result = prime * result + ((webUrl == null) ? 0 : webUrl.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("Camera [isStreaming=").append(isStreaming).append(", isAudioInputEnabled=")
- .append(isAudioInputEnabled).append(", lastIsOnlineChange=").append(lastIsOnlineChange)
- .append(", isVideoHistoryEnabled=").append(isVideoHistoryEnabled).append(", webUrl=").append(webUrl)
- .append(", appUrl=").append(appUrl).append(", isPublicShareEnabled=").append(isPublicShareEnabled)
- .append(", activityZones=").append(activityZones).append(", publicShareUrl=").append(publicShareUrl)
- .append(", snapshotUrl=").append(snapshotUrl).append(", lastEvent=").append(lastEvent)
- .append(", getId()=").append(getId()).append(", getName()=").append(getName())
- .append(", getDeviceId()=").append(getDeviceId()).append(", getLastConnection()=")
- .append(getLastConnection()).append(", isOnline()=").append(isOnline()).append(", getNameLong()=")
- .append(getNameLong()).append(", getSoftwareVersion()=").append(getSoftwareVersion())
- .append(", getStructureId()=").append(getStructureId()).append(", getWhereId()=").append(getWhereId())
- .append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Date;
-import java.util.List;
-
-/**
- * The data for a camera event.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Extract CameraEvent object from Camera
- * @author Wouter Born - Add equals, hashCode, toString methods
- */
-public class CameraEvent {
-
- private Boolean hasSound;
- private Boolean hasMotion;
- private Boolean hasPerson;
- private Date startTime;
- private Date endTime;
- private Date urlsExpireTime;
- private String webUrl;
- private String appUrl;
- private String imageUrl;
- private String animatedImageUrl;
- private List<String> activityZoneIds;
-
- public Boolean isHasSound() {
- return hasSound;
- }
-
- public Boolean isHasMotion() {
- return hasMotion;
- }
-
- public Boolean isHasPerson() {
- return hasPerson;
- }
-
- public Date getStartTime() {
- return startTime;
- }
-
- public Date getEndTime() {
- return endTime;
- }
-
- public Date getUrlsExpireTime() {
- return urlsExpireTime;
- }
-
- public String getWebUrl() {
- return webUrl;
- }
-
- public String getAppUrl() {
- return appUrl;
- }
-
- public String getImageUrl() {
- return imageUrl;
- }
-
- public String getAnimatedImageUrl() {
- return animatedImageUrl;
- }
-
- public List<String> getActivityZones() {
- return activityZoneIds;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- CameraEvent other = (CameraEvent) obj;
- if (activityZoneIds == null) {
- if (other.activityZoneIds != null) {
- return false;
- }
- } else if (!activityZoneIds.equals(other.activityZoneIds)) {
- return false;
- }
- if (animatedImageUrl == null) {
- if (other.animatedImageUrl != null) {
- return false;
- }
- } else if (!animatedImageUrl.equals(other.animatedImageUrl)) {
- return false;
- }
- if (appUrl == null) {
- if (other.appUrl != null) {
- return false;
- }
- } else if (!appUrl.equals(other.appUrl)) {
- return false;
- }
- if (endTime == null) {
- if (other.endTime != null) {
- return false;
- }
- } else if (!endTime.equals(other.endTime)) {
- return false;
- }
- if (hasMotion == null) {
- if (other.hasMotion != null) {
- return false;
- }
- } else if (!hasMotion.equals(other.hasMotion)) {
- return false;
- }
- if (hasPerson == null) {
- if (other.hasPerson != null) {
- return false;
- }
- } else if (!hasPerson.equals(other.hasPerson)) {
- return false;
- }
- if (hasSound == null) {
- if (other.hasSound != null) {
- return false;
- }
- } else if (!hasSound.equals(other.hasSound)) {
- return false;
- }
- if (imageUrl == null) {
- if (other.imageUrl != null) {
- return false;
- }
- } else if (!imageUrl.equals(other.imageUrl)) {
- return false;
- }
- if (startTime == null) {
- if (other.startTime != null) {
- return false;
- }
- } else if (!startTime.equals(other.startTime)) {
- return false;
- }
- if (urlsExpireTime == null) {
- if (other.urlsExpireTime != null) {
- return false;
- }
- } else if (!urlsExpireTime.equals(other.urlsExpireTime)) {
- return false;
- }
- if (webUrl == null) {
- if (other.webUrl != null) {
- return false;
- }
- } else if (!webUrl.equals(other.webUrl)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((activityZoneIds == null) ? 0 : activityZoneIds.hashCode());
- result = prime * result + ((animatedImageUrl == null) ? 0 : animatedImageUrl.hashCode());
- result = prime * result + ((appUrl == null) ? 0 : appUrl.hashCode());
- result = prime * result + ((endTime == null) ? 0 : endTime.hashCode());
- result = prime * result + ((hasMotion == null) ? 0 : hasMotion.hashCode());
- result = prime * result + ((hasPerson == null) ? 0 : hasPerson.hashCode());
- result = prime * result + ((hasSound == null) ? 0 : hasSound.hashCode());
- result = prime * result + ((imageUrl == null) ? 0 : imageUrl.hashCode());
- result = prime * result + ((startTime == null) ? 0 : startTime.hashCode());
- result = prime * result + ((urlsExpireTime == null) ? 0 : urlsExpireTime.hashCode());
- result = prime * result + ((webUrl == null) ? 0 : webUrl.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("Event [hasSound=").append(hasSound).append(", hasMotion=").append(hasMotion)
- .append(", hasPerson=").append(hasPerson).append(", startTime=").append(startTime).append(", endTime=")
- .append(endTime).append(", urlsExpireTime=").append(urlsExpireTime).append(", webUrl=").append(webUrl)
- .append(", appUrl=").append(appUrl).append(", imageUrl=").append(imageUrl).append(", animatedImageUrl=")
- .append(animatedImageUrl).append(", activityZoneIds=").append(activityZoneIds).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Date;
-
-/**
- * Used to set and update the ETA values for Nest.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Extract ETA object from Structure
- * @author Wouter Born - Add equals, hashCode, toString methods
- */
-public class ETA {
-
- private String tripId;
- private Date estimatedArrivalWindowBegin;
- private Date estimatedArrivalWindowEnd;
-
- public String getTripId() {
- return tripId;
- }
-
- public void setTripId(String tripId) {
- this.tripId = tripId;
- }
-
- public Date getEstimatedArrivalWindowBegin() {
- return estimatedArrivalWindowBegin;
- }
-
- public void setEstimatedArrivalWindowBegin(Date estimatedArrivalWindowBegin) {
- this.estimatedArrivalWindowBegin = estimatedArrivalWindowBegin;
- }
-
- public Date getEstimatedArrivalWindowEnd() {
- return estimatedArrivalWindowEnd;
- }
-
- public void setEstimatedArrivalWindowEnd(Date estimatedArrivalWindowEnd) {
- this.estimatedArrivalWindowEnd = estimatedArrivalWindowEnd;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- ETA other = (ETA) obj;
- if (estimatedArrivalWindowBegin == null) {
- if (other.estimatedArrivalWindowBegin != null) {
- return false;
- }
- } else if (!estimatedArrivalWindowBegin.equals(other.estimatedArrivalWindowBegin)) {
- return false;
- }
- if (estimatedArrivalWindowEnd == null) {
- if (other.estimatedArrivalWindowEnd != null) {
- return false;
- }
- } else if (!estimatedArrivalWindowEnd.equals(other.estimatedArrivalWindowEnd)) {
- return false;
- }
- if (tripId == null) {
- if (other.tripId != null) {
- return false;
- }
- } else if (!tripId.equals(other.tripId)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((estimatedArrivalWindowBegin == null) ? 0 : estimatedArrivalWindowBegin.hashCode());
- result = prime * result + ((estimatedArrivalWindowEnd == null) ? 0 : estimatedArrivalWindowEnd.hashCode());
- result = prime * result + ((tripId == null) ? 0 : tripId.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("ETA [tripId=").append(tripId).append(", estimatedArrivalWindowBegin=")
- .append(estimatedArrivalWindowBegin).append(", estimatedArrivalWindowEnd=")
- .append(estimatedArrivalWindowEnd).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * The data of Nest API errors.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Improve exception handling
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class ErrorData {
-
- private String error;
- private String type;
- private String message;
- private String instance;
-
- public String getError() {
- return error;
- }
-
- public String getType() {
- return type;
- }
-
- public String getMessage() {
- return message;
- }
-
- public String getInstance() {
- return instance;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- ErrorData other = (ErrorData) obj;
- if (error == null) {
- if (other.error != null) {
- return false;
- }
- } else if (!error.equals(other.error)) {
- return false;
- }
- if (instance == null) {
- if (other.instance != null) {
- return false;
- }
- } else if (!instance.equals(other.instance)) {
- return false;
- }
- if (message == null) {
- if (other.message != null) {
- return false;
- }
- } else if (!message.equals(other.message)) {
- return false;
- }
- if (type == null) {
- if (other.type != null) {
- return false;
- }
- } else if (!type.equals(other.type)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((error == null) ? 0 : error.hashCode());
- result = prime * result + ((instance == null) ? 0 : instance.hashCode());
- result = prime * result + ((message == null) ? 0 : message.hashCode());
- result = prime * result + ((type == null) ? 0 : type.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("ErrorData [error=").append(error).append(", type=").append(type).append(", message=")
- .append(message).append(", instance=").append(instance).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Map;
-
-/**
- * All the Nest devices broken up by type.
- *
- * @author David Bennett - Initial contribution
- */
-public class NestDevices {
-
- private Map<String, Thermostat> thermostats;
- private Map<String, SmokeDetector> smokeCoAlarms;
- private Map<String, Camera> cameras;
-
- /** Id to thermostat mapping */
- public Map<String, Thermostat> getThermostats() {
- return thermostats;
- }
-
- /** Id to camera mapping */
- public Map<String, Camera> getCameras() {
- return cameras;
- }
-
- /** Id to smoke detector */
- public Map<String, SmokeDetector> getSmokeCoAlarms() {
- return smokeCoAlarms;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- NestDevices other = (NestDevices) obj;
- if (cameras == null) {
- if (other.cameras != null) {
- return false;
- }
- } else if (!cameras.equals(other.cameras)) {
- return false;
- }
- if (smokeCoAlarms == null) {
- if (other.smokeCoAlarms != null) {
- return false;
- }
- } else if (!smokeCoAlarms.equals(other.smokeCoAlarms)) {
- return false;
- }
- if (thermostats == null) {
- if (other.thermostats != null) {
- return false;
- }
- } else if (!thermostats.equals(other.thermostats)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((cameras == null) ? 0 : cameras.hashCode());
- result = prime * result + ((smokeCoAlarms == null) ? 0 : smokeCoAlarms.hashCode());
- result = prime * result + ((thermostats == null) ? 0 : thermostats.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("NestDevices [thermostats=").append(thermostats).append(", smokeCoAlarms=").append(smokeCoAlarms)
- .append(", cameras=").append(cameras).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * Interface for uniquely identifiable Nest objects (device or a structure).
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Simplify working with deviceId and structureId
- */
-public interface NestIdentifiable {
-
- /**
- * Returns the identifier that uniquely identifies the Nest object (deviceId or structureId).
- */
- String getId();
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * The meta data in the data downloads from Nest.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class NestMetadata {
-
- private String accessToken;
- private String clientVersion;
-
- public String getAccessToken() {
- return accessToken;
- }
-
- public String getClientVersion() {
- return clientVersion;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- NestMetadata other = (NestMetadata) obj;
- if (accessToken == null) {
- if (other.accessToken != null) {
- return false;
- }
- } else if (!accessToken.equals(other.accessToken)) {
- return false;
- }
- if (clientVersion == null) {
- if (other.clientVersion != null) {
- return false;
- }
- } else if (!clientVersion.equals(other.clientVersion)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((accessToken == null) ? 0 : accessToken.hashCode());
- result = prime * result + ((clientVersion == null) ? 0 : clientVersion.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("NestMetadata [accessToken=").append(accessToken).append(", clientVersion=")
- .append(clientVersion).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Date;
-
-import com.google.gson.annotations.SerializedName;
-
-/**
- * Data for the Nest smoke detector.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class SmokeDetector extends BaseNestDevice {
-
- private BatteryHealth batteryHealth;
- private AlarmState coAlarmState;
- private Date lastManualTestTime;
- private AlarmState smokeAlarmState;
- private Boolean isManualTestActive;
- private UiColorState uiColorState;
-
- public UiColorState getUiColorState() {
- return uiColorState;
- }
-
- public BatteryHealth getBatteryHealth() {
- return batteryHealth;
- }
-
- public AlarmState getCoAlarmState() {
- return coAlarmState;
- }
-
- public Date getLastManualTestTime() {
- return lastManualTestTime;
- }
-
- public AlarmState getSmokeAlarmState() {
- return smokeAlarmState;
- }
-
- public Boolean isManualTestActive() {
- return isManualTestActive;
- }
-
- public enum BatteryHealth {
- @SerializedName("ok")
- OK,
- @SerializedName("replace")
- REPLACE
- }
-
- public enum AlarmState {
- @SerializedName("ok")
- OK,
- @SerializedName("emergency")
- EMERGENCY,
- @SerializedName("warning")
- WARNING
- }
-
- public enum UiColorState {
- @SerializedName("gray")
- GRAY,
- @SerializedName("green")
- GREEN,
- @SerializedName("yellow")
- YELLOW,
- @SerializedName("red")
- RED
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (!super.equals(obj)) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- SmokeDetector other = (SmokeDetector) obj;
- if (batteryHealth != other.batteryHealth) {
- return false;
- }
- if (coAlarmState != other.coAlarmState) {
- return false;
- }
- if (isManualTestActive == null) {
- if (other.isManualTestActive != null) {
- return false;
- }
- } else if (!isManualTestActive.equals(other.isManualTestActive)) {
- return false;
- }
- if (lastManualTestTime == null) {
- if (other.lastManualTestTime != null) {
- return false;
- }
- } else if (!lastManualTestTime.equals(other.lastManualTestTime)) {
- return false;
- }
- if (smokeAlarmState != other.smokeAlarmState) {
- return false;
- }
- if (uiColorState != other.uiColorState) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = super.hashCode();
- result = prime * result + ((batteryHealth == null) ? 0 : batteryHealth.hashCode());
- result = prime * result + ((coAlarmState == null) ? 0 : coAlarmState.hashCode());
- result = prime * result + ((isManualTestActive == null) ? 0 : isManualTestActive.hashCode());
- result = prime * result + ((lastManualTestTime == null) ? 0 : lastManualTestTime.hashCode());
- result = prime * result + ((smokeAlarmState == null) ? 0 : smokeAlarmState.hashCode());
- result = prime * result + ((uiColorState == null) ? 0 : uiColorState.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("SmokeDetector [batteryHealth=").append(batteryHealth).append(", coAlarmState=")
- .append(coAlarmState).append(", lastManualTestTime=").append(lastManualTestTime)
- .append(", smokeAlarmState=").append(smokeAlarmState).append(", isManualTestActive=")
- .append(isManualTestActive).append(", uiColorState=").append(uiColorState).append(", getId()=")
- .append(getId()).append(", getName()=").append(getName()).append(", getDeviceId()=")
- .append(getDeviceId()).append(", getLastConnection()=").append(getLastConnection())
- .append(", isOnline()=").append(isOnline()).append(", getNameLong()=").append(getNameLong())
- .append(", getSoftwareVersion()=").append(getSoftwareVersion()).append(", getStructureId()=")
- .append(getStructureId()).append(", getWhereId()=").append(getWhereId()).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-
-import org.openhab.binding.nest.internal.data.SmokeDetector.AlarmState;
-
-import com.google.gson.annotations.SerializedName;
-
-/**
- * The structure details from Nest.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class Structure implements NestIdentifiable {
-
- private String structureId;
- private List<String> thermostats;
- private List<String> smokeCoAlarms;
- private List<String> cameras;
- private String countryCode;
- private String postalCode;
- private Date peakPeriodStartTime;
- private Date peakPeriodEndTime;
- private String timeZone;
- private Date etaBegin;
- private SmokeDetector.AlarmState coAlarmState;
- private SmokeDetector.AlarmState smokeAlarmState;
- private Boolean rhrEnrollment;
- private Map<String, Where> wheres;
- private HomeAwayState away;
- private String name;
- private ETA eta;
- private SecurityState wwnSecurityState;
-
- @Override
- public String getId() {
- return structureId;
- }
-
- public HomeAwayState getAway() {
- return away;
- }
-
- public void setAway(HomeAwayState away) {
- this.away = away;
- }
-
- public String getStructureId() {
- return structureId;
- }
-
- public List<String> getThermostats() {
- return thermostats;
- }
-
- public List<String> getSmokeCoAlarms() {
- return smokeCoAlarms;
- }
-
- public List<String> getCameras() {
- return cameras;
- }
-
- public String getCountryCode() {
- return countryCode;
- }
-
- public String getPostalCode() {
- return postalCode;
- }
-
- public Date getPeakPeriodStartTime() {
- return peakPeriodStartTime;
- }
-
- public Date getPeakPeriodEndTime() {
- return peakPeriodEndTime;
- }
-
- public String getTimeZone() {
- return timeZone;
- }
-
- public Date getEtaBegin() {
- return etaBegin;
- }
-
- public AlarmState getCoAlarmState() {
- return coAlarmState;
- }
-
- public AlarmState getSmokeAlarmState() {
- return smokeAlarmState;
- }
-
- public Boolean isRhrEnrollment() {
- return rhrEnrollment;
- }
-
- public Map<String, Where> getWheres() {
- return wheres;
- }
-
- public ETA getEta() {
- return eta;
- }
-
- public String getName() {
- return name;
- }
-
- public SecurityState getWwnSecurityState() {
- return wwnSecurityState;
- }
-
- public enum HomeAwayState {
- @SerializedName("home")
- HOME,
- @SerializedName("away")
- AWAY,
- @SerializedName("unknown")
- UNKNOWN
- }
-
- public enum SecurityState {
- @SerializedName("ok")
- OK,
- @SerializedName("deter")
- DETER
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- Structure other = (Structure) obj;
- if (away != other.away) {
- return false;
- }
- if (cameras == null) {
- if (other.cameras != null) {
- return false;
- }
- } else if (!cameras.equals(other.cameras)) {
- return false;
- }
- if (coAlarmState != other.coAlarmState) {
- return false;
- }
- if (countryCode == null) {
- if (other.countryCode != null) {
- return false;
- }
- } else if (!countryCode.equals(other.countryCode)) {
- return false;
- }
- if (eta == null) {
- if (other.eta != null) {
- return false;
- }
- } else if (!eta.equals(other.eta)) {
- return false;
- }
- if (etaBegin == null) {
- if (other.etaBegin != null) {
- return false;
- }
- } else if (!etaBegin.equals(other.etaBegin)) {
- return false;
- }
- if (name == null) {
- if (other.name != null) {
- return false;
- }
- } else if (!name.equals(other.name)) {
- return false;
- }
- if (peakPeriodEndTime == null) {
- if (other.peakPeriodEndTime != null) {
- return false;
- }
- } else if (!peakPeriodEndTime.equals(other.peakPeriodEndTime)) {
- return false;
- }
- if (peakPeriodStartTime == null) {
- if (other.peakPeriodStartTime != null) {
- return false;
- }
- } else if (!peakPeriodStartTime.equals(other.peakPeriodStartTime)) {
- return false;
- }
- if (postalCode == null) {
- if (other.postalCode != null) {
- return false;
- }
- } else if (!postalCode.equals(other.postalCode)) {
- return false;
- }
- if (rhrEnrollment == null) {
- if (other.rhrEnrollment != null) {
- return false;
- }
- } else if (!rhrEnrollment.equals(other.rhrEnrollment)) {
- return false;
- }
- if (smokeAlarmState != other.smokeAlarmState) {
- return false;
- }
- if (smokeCoAlarms == null) {
- if (other.smokeCoAlarms != null) {
- return false;
- }
- } else if (!smokeCoAlarms.equals(other.smokeCoAlarms)) {
- return false;
- }
- if (structureId == null) {
- if (other.structureId != null) {
- return false;
- }
- } else if (!structureId.equals(other.structureId)) {
- return false;
- }
- if (thermostats == null) {
- if (other.thermostats != null) {
- return false;
- }
- } else if (!thermostats.equals(other.thermostats)) {
- return false;
- }
- if (timeZone == null) {
- if (other.timeZone != null) {
- return false;
- }
- } else if (!timeZone.equals(other.timeZone)) {
- return false;
- }
- if (wheres == null) {
- if (other.wheres != null) {
- return false;
- }
- } else if (!wheres.equals(other.wheres)) {
- return false;
- }
- if (wwnSecurityState != other.wwnSecurityState) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((away == null) ? 0 : away.hashCode());
- result = prime * result + ((cameras == null) ? 0 : cameras.hashCode());
- result = prime * result + ((coAlarmState == null) ? 0 : coAlarmState.hashCode());
- result = prime * result + ((countryCode == null) ? 0 : countryCode.hashCode());
- result = prime * result + ((eta == null) ? 0 : eta.hashCode());
- result = prime * result + ((etaBegin == null) ? 0 : etaBegin.hashCode());
- result = prime * result + ((name == null) ? 0 : name.hashCode());
- result = prime * result + ((peakPeriodEndTime == null) ? 0 : peakPeriodEndTime.hashCode());
- result = prime * result + ((peakPeriodStartTime == null) ? 0 : peakPeriodStartTime.hashCode());
- result = prime * result + ((postalCode == null) ? 0 : postalCode.hashCode());
- result = prime * result + ((rhrEnrollment == null) ? 0 : rhrEnrollment.hashCode());
- result = prime * result + ((smokeAlarmState == null) ? 0 : smokeAlarmState.hashCode());
- result = prime * result + ((smokeCoAlarms == null) ? 0 : smokeCoAlarms.hashCode());
- result = prime * result + ((structureId == null) ? 0 : structureId.hashCode());
- result = prime * result + ((thermostats == null) ? 0 : thermostats.hashCode());
- result = prime * result + ((timeZone == null) ? 0 : timeZone.hashCode());
- result = prime * result + ((wheres == null) ? 0 : wheres.hashCode());
- result = prime * result + ((wwnSecurityState == null) ? 0 : wwnSecurityState.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("Structure [structureId=").append(structureId).append(", thermostats=").append(thermostats)
- .append(", smokeCoAlarms=").append(smokeCoAlarms).append(", cameras=").append(cameras)
- .append(", countryCode=").append(countryCode).append(", postalCode=").append(postalCode)
- .append(", peakPeriodStartTime=").append(peakPeriodStartTime).append(", peakPeriodEndTime=")
- .append(peakPeriodEndTime).append(", timeZone=").append(timeZone).append(", etaBegin=").append(etaBegin)
- .append(", coAlarmState=").append(coAlarmState).append(", smokeAlarmState=").append(smokeAlarmState)
- .append(", rhrEnrollment=").append(rhrEnrollment).append(", wheres=").append(wheres).append(", away=")
- .append(away).append(", name=").append(name).append(", eta=").append(eta).append(", wwnSecurityState=")
- .append(wwnSecurityState).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
-import static org.openhab.core.library.unit.SIUnits.CELSIUS;
-
-import java.util.Date;
-
-import javax.measure.Unit;
-import javax.measure.quantity.Temperature;
-
-import com.google.gson.annotations.SerializedName;
-
-/**
- * Gson class to encapsulate the data for the Nest thermostat.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class Thermostat extends BaseNestDevice {
-
- private Boolean canCool;
- private Boolean canHeat;
- private Boolean isUsingEmergencyHeat;
- private Boolean hasFan;
- private Boolean fanTimerActive;
- private Date fanTimerTimeout;
- private Boolean hasLeaf;
- private String temperatureScale;
- private Double ambientTemperatureC;
- private Double ambientTemperatureF;
- private Integer humidity;
- private Double targetTemperatureC;
- private Double targetTemperatureF;
- private Double targetTemperatureHighC;
- private Double targetTemperatureHighF;
- private Double targetTemperatureLowC;
- private Double targetTemperatureLowF;
- private Mode hvacMode;
- private Mode previousHvacMode;
- private State hvacState;
- private Double ecoTemperatureHighC;
- private Double ecoTemperatureHighF;
- private Double ecoTemperatureLowC;
- private Double ecoTemperatureLowF;
- private Boolean isLocked;
- private Double lockedTempMaxC;
- private Double lockedTempMaxF;
- private Double lockedTempMinC;
- private Double lockedTempMinF;
- private Boolean sunlightCorrectionEnabled;
- private Boolean sunlightCorrectionActive;
- private Integer fanTimerDuration;
- private String timeToTarget;
- private String whereName;
-
- public Unit<Temperature> getTemperatureUnit() {
- if ("C".equals(temperatureScale)) {
- return CELSIUS;
- } else if ("F".equals(temperatureScale)) {
- return FAHRENHEIT;
- } else {
- return null;
- }
- }
-
- public Double getTargetTemperature() {
- if (getTemperatureUnit() == CELSIUS) {
- return targetTemperatureC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return targetTemperatureF;
- } else {
- return null;
- }
- }
-
- public Double getTargetTemperatureHigh() {
- if (getTemperatureUnit() == CELSIUS) {
- return targetTemperatureHighC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return targetTemperatureHighF;
- } else {
- return null;
- }
- }
-
- public Double getTargetTemperatureLow() {
- if (getTemperatureUnit() == CELSIUS) {
- return targetTemperatureLowC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return targetTemperatureLowF;
- } else {
- return null;
- }
- }
-
- public Mode getMode() {
- return hvacMode;
- }
-
- public Double getEcoTemperatureHigh() {
- if (getTemperatureUnit() == CELSIUS) {
- return ecoTemperatureHighC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return ecoTemperatureHighF;
- } else {
- return null;
- }
- }
-
- public Double getEcoTemperatureLow() {
- if (getTemperatureUnit() == CELSIUS) {
- return ecoTemperatureLowC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return ecoTemperatureLowF;
- } else {
- return null;
- }
- }
-
- public Boolean isLocked() {
- return isLocked;
- }
-
- public Double getLockedTempMax() {
- if (getTemperatureUnit() == CELSIUS) {
- return lockedTempMaxC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return lockedTempMaxF;
- } else {
- return null;
- }
- }
-
- public Double getLockedTempMin() {
- if (getTemperatureUnit() == CELSIUS) {
- return lockedTempMinC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return lockedTempMinF;
- } else {
- return null;
- }
- }
-
- public Boolean isCanCool() {
- return canCool;
- }
-
- public Boolean isCanHeat() {
- return canHeat;
- }
-
- public Boolean isUsingEmergencyHeat() {
- return isUsingEmergencyHeat;
- }
-
- public Boolean isHasFan() {
- return hasFan;
- }
-
- public Boolean isFanTimerActive() {
- return fanTimerActive;
- }
-
- public Date getFanTimerTimeout() {
- return fanTimerTimeout;
- }
-
- public Boolean isHasLeaf() {
- return hasLeaf;
- }
-
- public Mode getPreviousHvacMode() {
- return previousHvacMode;
- }
-
- public State getHvacState() {
- return hvacState;
- }
-
- public Boolean isSunlightCorrectionEnabled() {
- return sunlightCorrectionEnabled;
- }
-
- public Boolean isSunlightCorrectionActive() {
- return sunlightCorrectionActive;
- }
-
- public Integer getFanTimerDuration() {
- return fanTimerDuration;
- }
-
- public Integer getTimeToTarget() {
- return parseTimeToTarget(timeToTarget);
- }
-
- /*
- * Turns the time to target string into a real value.
- */
- static Integer parseTimeToTarget(String timeToTarget) {
- if (timeToTarget == null) {
- return null;
- } else if (timeToTarget.startsWith("~") || timeToTarget.startsWith("<") || timeToTarget.startsWith(">")) {
- return Integer.valueOf(timeToTarget.substring(1));
- }
- return Integer.valueOf(timeToTarget);
- }
-
- public String getWhereName() {
- return whereName;
- }
-
- public Double getAmbientTemperature() {
- if (getTemperatureUnit() == CELSIUS) {
- return ambientTemperatureC;
- } else if (getTemperatureUnit() == FAHRENHEIT) {
- return ambientTemperatureF;
- } else {
- return null;
- }
- }
-
- public Integer getHumidity() {
- return humidity;
- }
-
- public enum Mode {
- @SerializedName("heat")
- HEAT,
- @SerializedName("cool")
- COOL,
- @SerializedName("heat-cool")
- HEAT_COOL,
- @SerializedName("eco")
- ECO,
- @SerializedName("off")
- OFF
- }
-
- public enum State {
- @SerializedName("heating")
- HEATING,
- @SerializedName("cooling")
- COOLING,
- @SerializedName("off")
- OFF
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (!super.equals(obj)) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- Thermostat other = (Thermostat) obj;
- if (ambientTemperatureC == null) {
- if (other.ambientTemperatureC != null) {
- return false;
- }
- } else if (!ambientTemperatureC.equals(other.ambientTemperatureC)) {
- return false;
- }
- if (ambientTemperatureF == null) {
- if (other.ambientTemperatureF != null) {
- return false;
- }
- } else if (!ambientTemperatureF.equals(other.ambientTemperatureF)) {
- return false;
- }
- if (canCool == null) {
- if (other.canCool != null) {
- return false;
- }
- } else if (!canCool.equals(other.canCool)) {
- return false;
- }
- if (canHeat == null) {
- if (other.canHeat != null) {
- return false;
- }
- } else if (!canHeat.equals(other.canHeat)) {
- return false;
- }
- if (ecoTemperatureHighC == null) {
- if (other.ecoTemperatureHighC != null) {
- return false;
- }
- } else if (!ecoTemperatureHighC.equals(other.ecoTemperatureHighC)) {
- return false;
- }
- if (ecoTemperatureHighF == null) {
- if (other.ecoTemperatureHighF != null) {
- return false;
- }
- } else if (!ecoTemperatureHighF.equals(other.ecoTemperatureHighF)) {
- return false;
- }
- if (ecoTemperatureLowC == null) {
- if (other.ecoTemperatureLowC != null) {
- return false;
- }
- } else if (!ecoTemperatureLowC.equals(other.ecoTemperatureLowC)) {
- return false;
- }
- if (ecoTemperatureLowF == null) {
- if (other.ecoTemperatureLowF != null) {
- return false;
- }
- } else if (!ecoTemperatureLowF.equals(other.ecoTemperatureLowF)) {
- return false;
- }
- if (fanTimerActive == null) {
- if (other.fanTimerActive != null) {
- return false;
- }
- } else if (!fanTimerActive.equals(other.fanTimerActive)) {
- return false;
- }
- if (fanTimerDuration == null) {
- if (other.fanTimerDuration != null) {
- return false;
- }
- } else if (!fanTimerDuration.equals(other.fanTimerDuration)) {
- return false;
- }
- if (fanTimerTimeout == null) {
- if (other.fanTimerTimeout != null) {
- return false;
- }
- } else if (!fanTimerTimeout.equals(other.fanTimerTimeout)) {
- return false;
- }
- if (hasFan == null) {
- if (other.hasFan != null) {
- return false;
- }
- } else if (!hasFan.equals(other.hasFan)) {
- return false;
- }
- if (hasLeaf == null) {
- if (other.hasLeaf != null) {
- return false;
- }
- } else if (!hasLeaf.equals(other.hasLeaf)) {
- return false;
- }
- if (humidity == null) {
- if (other.humidity != null) {
- return false;
- }
- } else if (!humidity.equals(other.humidity)) {
- return false;
- }
- if (hvacMode != other.hvacMode) {
- return false;
- }
- if (hvacState != other.hvacState) {
- return false;
- }
- if (isLocked == null) {
- if (other.isLocked != null) {
- return false;
- }
- } else if (!isLocked.equals(other.isLocked)) {
- return false;
- }
- if (isUsingEmergencyHeat == null) {
- if (other.isUsingEmergencyHeat != null) {
- return false;
- }
- } else if (!isUsingEmergencyHeat.equals(other.isUsingEmergencyHeat)) {
- return false;
- }
- if (lockedTempMaxC == null) {
- if (other.lockedTempMaxC != null) {
- return false;
- }
- } else if (!lockedTempMaxC.equals(other.lockedTempMaxC)) {
- return false;
- }
- if (lockedTempMaxF == null) {
- if (other.lockedTempMaxF != null) {
- return false;
- }
- } else if (!lockedTempMaxF.equals(other.lockedTempMaxF)) {
- return false;
- }
- if (lockedTempMinC == null) {
- if (other.lockedTempMinC != null) {
- return false;
- }
- } else if (!lockedTempMinC.equals(other.lockedTempMinC)) {
- return false;
- }
- if (lockedTempMinF == null) {
- if (other.lockedTempMinF != null) {
- return false;
- }
- } else if (!lockedTempMinF.equals(other.lockedTempMinF)) {
- return false;
- }
- if (previousHvacMode != other.previousHvacMode) {
- return false;
- }
- if (sunlightCorrectionActive == null) {
- if (other.sunlightCorrectionActive != null) {
- return false;
- }
- } else if (!sunlightCorrectionActive.equals(other.sunlightCorrectionActive)) {
- return false;
- }
- if (sunlightCorrectionEnabled == null) {
- if (other.sunlightCorrectionEnabled != null) {
- return false;
- }
- } else if (!sunlightCorrectionEnabled.equals(other.sunlightCorrectionEnabled)) {
- return false;
- }
- if (targetTemperatureC == null) {
- if (other.targetTemperatureC != null) {
- return false;
- }
- } else if (!targetTemperatureC.equals(other.targetTemperatureC)) {
- return false;
- }
- if (targetTemperatureF == null) {
- if (other.targetTemperatureF != null) {
- return false;
- }
- } else if (!targetTemperatureF.equals(other.targetTemperatureF)) {
- return false;
- }
- if (targetTemperatureHighC == null) {
- if (other.targetTemperatureHighC != null) {
- return false;
- }
- } else if (!targetTemperatureHighC.equals(other.targetTemperatureHighC)) {
- return false;
- }
- if (targetTemperatureHighF == null) {
- if (other.targetTemperatureHighF != null) {
- return false;
- }
- } else if (!targetTemperatureHighF.equals(other.targetTemperatureHighF)) {
- return false;
- }
- if (targetTemperatureLowC == null) {
- if (other.targetTemperatureLowC != null) {
- return false;
- }
- } else if (!targetTemperatureLowC.equals(other.targetTemperatureLowC)) {
- return false;
- }
- if (targetTemperatureLowF == null) {
- if (other.targetTemperatureLowF != null) {
- return false;
- }
- } else if (!targetTemperatureLowF.equals(other.targetTemperatureLowF)) {
- return false;
- }
- if (temperatureScale == null) {
- if (other.temperatureScale != null) {
- return false;
- }
- } else if (!temperatureScale.equals(other.temperatureScale)) {
- return false;
- }
- if (timeToTarget == null) {
- if (other.timeToTarget != null) {
- return false;
- }
- } else if (!timeToTarget.equals(other.timeToTarget)) {
- return false;
- }
- if (whereName == null) {
- if (other.whereName != null) {
- return false;
- }
- } else if (!whereName.equals(other.whereName)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = super.hashCode();
- result = prime * result + ((ambientTemperatureC == null) ? 0 : ambientTemperatureC.hashCode());
- result = prime * result + ((ambientTemperatureF == null) ? 0 : ambientTemperatureF.hashCode());
- result = prime * result + ((canCool == null) ? 0 : canCool.hashCode());
- result = prime * result + ((canHeat == null) ? 0 : canHeat.hashCode());
- result = prime * result + ((ecoTemperatureHighC == null) ? 0 : ecoTemperatureHighC.hashCode());
- result = prime * result + ((ecoTemperatureHighF == null) ? 0 : ecoTemperatureHighF.hashCode());
- result = prime * result + ((ecoTemperatureLowC == null) ? 0 : ecoTemperatureLowC.hashCode());
- result = prime * result + ((ecoTemperatureLowF == null) ? 0 : ecoTemperatureLowF.hashCode());
- result = prime * result + ((fanTimerActive == null) ? 0 : fanTimerActive.hashCode());
- result = prime * result + ((fanTimerDuration == null) ? 0 : fanTimerDuration.hashCode());
- result = prime * result + ((fanTimerTimeout == null) ? 0 : fanTimerTimeout.hashCode());
- result = prime * result + ((hasFan == null) ? 0 : hasFan.hashCode());
- result = prime * result + ((hasLeaf == null) ? 0 : hasLeaf.hashCode());
- result = prime * result + ((humidity == null) ? 0 : humidity.hashCode());
- result = prime * result + ((hvacMode == null) ? 0 : hvacMode.hashCode());
- result = prime * result + ((hvacState == null) ? 0 : hvacState.hashCode());
- result = prime * result + ((isLocked == null) ? 0 : isLocked.hashCode());
- result = prime * result + ((isUsingEmergencyHeat == null) ? 0 : isUsingEmergencyHeat.hashCode());
- result = prime * result + ((lockedTempMaxC == null) ? 0 : lockedTempMaxC.hashCode());
- result = prime * result + ((lockedTempMaxF == null) ? 0 : lockedTempMaxF.hashCode());
- result = prime * result + ((lockedTempMinC == null) ? 0 : lockedTempMinC.hashCode());
- result = prime * result + ((lockedTempMinF == null) ? 0 : lockedTempMinF.hashCode());
- result = prime * result + ((previousHvacMode == null) ? 0 : previousHvacMode.hashCode());
- result = prime * result + ((sunlightCorrectionActive == null) ? 0 : sunlightCorrectionActive.hashCode());
- result = prime * result + ((sunlightCorrectionEnabled == null) ? 0 : sunlightCorrectionEnabled.hashCode());
- result = prime * result + ((targetTemperatureC == null) ? 0 : targetTemperatureC.hashCode());
- result = prime * result + ((targetTemperatureF == null) ? 0 : targetTemperatureF.hashCode());
- result = prime * result + ((targetTemperatureHighC == null) ? 0 : targetTemperatureHighC.hashCode());
- result = prime * result + ((targetTemperatureHighF == null) ? 0 : targetTemperatureHighF.hashCode());
- result = prime * result + ((targetTemperatureLowC == null) ? 0 : targetTemperatureLowC.hashCode());
- result = prime * result + ((targetTemperatureLowF == null) ? 0 : targetTemperatureLowF.hashCode());
- result = prime * result + ((temperatureScale == null) ? 0 : temperatureScale.hashCode());
- result = prime * result + ((timeToTarget == null) ? 0 : timeToTarget.hashCode());
- result = prime * result + ((whereName == null) ? 0 : whereName.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("Thermostat [canCool=").append(canCool).append(", canHeat=").append(canHeat)
- .append(", isUsingEmergencyHeat=").append(isUsingEmergencyHeat).append(", hasFan=").append(hasFan)
- .append(", fanTimerActive=").append(fanTimerActive).append(", fanTimerTimeout=").append(fanTimerTimeout)
- .append(", hasLeaf=").append(hasLeaf).append(", temperatureScale=").append(temperatureScale)
- .append(", ambientTemperatureC=").append(ambientTemperatureC).append(", ambientTemperatureF=")
- .append(ambientTemperatureF).append(", humidity=").append(humidity).append(", targetTemperatureC=")
- .append(targetTemperatureC).append(", targetTemperatureF=").append(targetTemperatureF)
- .append(", targetTemperatureHighC=").append(targetTemperatureHighC).append(", targetTemperatureHighF=")
- .append(targetTemperatureHighF).append(", targetTemperatureLowC=").append(targetTemperatureLowC)
- .append(", targetTemperatureLowF=").append(targetTemperatureLowF).append(", hvacMode=").append(hvacMode)
- .append(", previousHvacMode=").append(previousHvacMode).append(", hvacState=").append(hvacState)
- .append(", ecoTemperatureHighC=").append(ecoTemperatureHighC).append(", ecoTemperatureHighF=")
- .append(ecoTemperatureHighF).append(", ecoTemperatureLowC=").append(ecoTemperatureLowC)
- .append(", ecoTemperatureLowF=").append(ecoTemperatureLowF).append(", isLocked=").append(isLocked)
- .append(", lockedTempMaxC=").append(lockedTempMaxC).append(", lockedTempMaxF=").append(lockedTempMaxF)
- .append(", lockedTempMinC=").append(lockedTempMinC).append(", lockedTempMinF=").append(lockedTempMinF)
- .append(", sunlightCorrectionEnabled=").append(sunlightCorrectionEnabled)
- .append(", sunlightCorrectionActive=").append(sunlightCorrectionActive).append(", fanTimerDuration=")
- .append(fanTimerDuration).append(", timeToTarget=").append(timeToTarget).append(", whereName=")
- .append(whereName).append(", getId()=").append(getId()).append(", getName()=").append(getName())
- .append(", getDeviceId()=").append(getDeviceId()).append(", getLastConnection()=")
- .append(getLastConnection()).append(", isOnline()=").append(isOnline()).append(", getNameLong()=")
- .append(getNameLong()).append(", getSoftwareVersion()=").append(getSoftwareVersion())
- .append(", getStructureId()=").append(getStructureId()).append(", getWhereId()=").append(getWhereId())
- .append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.util.Map;
-
-/**
- * Top level data for all the Nest stuff, this is the format the Nest data comes back from Nest in.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class TopLevelData {
-
- private NestDevices devices;
- private NestMetadata metadata;
- private Map<String, Structure> structures;
-
- public NestDevices getDevices() {
- return devices;
- }
-
- public NestMetadata getMetadata() {
- return metadata;
- }
-
- public Map<String, Structure> getStructures() {
- return structures;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- TopLevelData other = (TopLevelData) obj;
- if (devices == null) {
- if (other.devices != null) {
- return false;
- }
- } else if (!devices.equals(other.devices)) {
- return false;
- }
- if (metadata == null) {
- if (other.metadata != null) {
- return false;
- }
- } else if (!metadata.equals(other.metadata)) {
- return false;
- }
- if (structures == null) {
- if (other.structures != null) {
- return false;
- }
- } else if (!structures.equals(other.structures)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((devices == null) ? 0 : devices.hashCode());
- result = prime * result + ((metadata == null) ? 0 : metadata.hashCode());
- result = prime * result + ((structures == null) ? 0 : structures.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("TopLevelData [devices=").append(devices).append(", metadata=").append(metadata)
- .append(", structures=").append(structures).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * The top level data that is sent by Nest to a streaming REST client using SSE.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Replace polling with REST streaming
- * @author Wouter Born - Add equals and hashCode methods
- */
-public class TopLevelStreamingData {
-
- private String path;
- private TopLevelData data;
-
- public String getPath() {
- return path;
- }
-
- public TopLevelData getData() {
- return data;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((data == null) ? 0 : data.hashCode());
- result = prime * result + ((path == null) ? 0 : path.hashCode());
- return result;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- TopLevelStreamingData other = (TopLevelStreamingData) obj;
- if (data == null) {
- if (other.data != null) {
- return false;
- }
- } else if (!data.equals(other.data)) {
- return false;
- }
- if (path == null) {
- if (other.path != null) {
- return false;
- }
- } else if (!path.equals(other.path)) {
- return false;
- }
- return true;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("TopLevelStreamingData [path=").append(path).append(", data=").append(data).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-/**
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Extract Where object from Structure
- * @author Wouter Born - Add equals, hashCode, toString methods
- */
-public class Where {
- private String whereId;
- private String name;
-
- public String getWhereId() {
- return whereId;
- }
-
- public String getName() {
- return name;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj) {
- return true;
- }
- if (obj == null) {
- return false;
- }
- if (getClass() != obj.getClass()) {
- return false;
- }
- Where other = (Where) obj;
- if (name == null) {
- if (other.name != null) {
- return false;
- }
- } else if (!name.equals(other.name)) {
- return false;
- }
- if (whereId == null) {
- if (other.whereId != null) {
- return false;
- }
- } else if (!whereId.equals(other.whereId)) {
- return false;
- }
- return true;
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((name == null) ? 0 : name.hashCode());
- result = prime * result + ((whereId == null) ? 0 : whereId.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- StringBuilder builder = new StringBuilder();
- builder.append("Where [whereId=").append(whereId).append(", name=").append(name).append("]");
- return builder.toString();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.discovery;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.BiConsumer;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.nest.internal.config.NestDeviceConfiguration;
-import org.openhab.binding.nest.internal.config.NestStructureConfiguration;
-import org.openhab.binding.nest.internal.data.BaseNestDevice;
-import org.openhab.binding.nest.internal.data.Camera;
-import org.openhab.binding.nest.internal.data.SmokeDetector;
-import org.openhab.binding.nest.internal.data.Structure;
-import org.openhab.binding.nest.internal.data.Thermostat;
-import org.openhab.binding.nest.internal.handler.NestBridgeHandler;
-import org.openhab.binding.nest.internal.listener.NestThingDataListener;
-import org.openhab.core.config.discovery.AbstractDiscoveryService;
-import org.openhab.core.config.discovery.DiscoveryResultBuilder;
-import org.openhab.core.thing.ThingTypeUID;
-import org.openhab.core.thing.ThingUID;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * This service connects to the Nest bridge and creates the correct discovery results for Nest devices
- * as they are found through the API.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Add representation properties
- */
-@NonNullByDefault
-public class NestDiscoveryService extends AbstractDiscoveryService {
-
- private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Stream
- .of(THING_TYPE_CAMERA, THING_TYPE_THERMOSTAT, THING_TYPE_SMOKE_DETECTOR, THING_TYPE_STRUCTURE)
- .collect(Collectors.toSet());
-
- private final Logger logger = LoggerFactory.getLogger(NestDiscoveryService.class);
-
- private final DiscoveryDataListener<Camera> cameraDiscoveryDataListener = new DiscoveryDataListener<>(Camera.class,
- THING_TYPE_CAMERA, this::addDeviceDiscoveryResult);
- private final DiscoveryDataListener<SmokeDetector> smokeDetectorDiscoveryDataListener = new DiscoveryDataListener<>(
- SmokeDetector.class, THING_TYPE_SMOKE_DETECTOR, this::addDeviceDiscoveryResult);
- private final DiscoveryDataListener<Structure> structureDiscoveryDataListener = new DiscoveryDataListener<>(
- Structure.class, THING_TYPE_STRUCTURE, this::addStructureDiscoveryResult);
- private final DiscoveryDataListener<Thermostat> thermostatDiscoveryDataListener = new DiscoveryDataListener<>(
- Thermostat.class, THING_TYPE_THERMOSTAT, this::addDeviceDiscoveryResult);
-
- @SuppressWarnings("rawtypes")
- private final List<DiscoveryDataListener> discoveryDataListeners = Stream.of(cameraDiscoveryDataListener,
- smokeDetectorDiscoveryDataListener, structureDiscoveryDataListener, thermostatDiscoveryDataListener)
- .collect(Collectors.toList());
-
- private final NestBridgeHandler bridge;
-
- private static class DiscoveryDataListener<T> implements NestThingDataListener<T> {
- private Class<T> dataClass;
- private ThingTypeUID thingTypeUID;
- private BiConsumer<T, ThingTypeUID> onDiscovered;
-
- private DiscoveryDataListener(Class<T> dataClass, ThingTypeUID thingTypeUID,
- BiConsumer<T, ThingTypeUID> onDiscovered) {
- this.dataClass = dataClass;
- this.thingTypeUID = thingTypeUID;
- this.onDiscovered = onDiscovered;
- }
-
- @Override
- public void onNewData(T data) {
- onDiscovered.accept(data, thingTypeUID);
- }
-
- @Override
- public void onUpdatedData(T oldData, T data) {
- }
-
- @Override
- public void onMissingData(String nestId) {
- }
- }
-
- public NestDiscoveryService(NestBridgeHandler bridge) {
- super(SUPPORTED_THING_TYPES, 60, true);
- this.bridge = bridge;
- }
-
- @SuppressWarnings("unchecked")
- public void activate() {
- discoveryDataListeners.forEach(l -> bridge.addThingDataListener(l.dataClass, l));
- addDiscoveryResultsFromLastUpdates();
- }
-
- @Override
- @SuppressWarnings("unchecked")
- public void deactivate() {
- discoveryDataListeners.forEach(l -> bridge.removeThingDataListener(l.dataClass, l));
- }
-
- @Override
- protected void startScan() {
- addDiscoveryResultsFromLastUpdates();
- }
-
- @SuppressWarnings("unchecked")
- private void addDiscoveryResultsFromLastUpdates() {
- discoveryDataListeners
- .forEach(l -> addDiscoveryResultsFromLastUpdates(l.dataClass, l.thingTypeUID, l.onDiscovered));
- }
-
- private <T> void addDiscoveryResultsFromLastUpdates(Class<T> dataClass, ThingTypeUID thingTypeUID,
- BiConsumer<T, ThingTypeUID> onDiscovered) {
- List<T> lastUpdates = bridge.getLastUpdates(dataClass);
- lastUpdates.forEach(lastUpdate -> onDiscovered.accept(lastUpdate, thingTypeUID));
- }
-
- private void addDeviceDiscoveryResult(BaseNestDevice device, ThingTypeUID typeUID) {
- ThingUID bridgeUID = bridge.getThing().getUID();
- ThingUID thingUID = new ThingUID(typeUID, bridgeUID, device.getDeviceId());
- logger.debug("Discovered {}", thingUID);
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestDeviceConfiguration.DEVICE_ID, device.getDeviceId());
- properties.put(PROPERTY_FIRMWARE_VERSION, device.getSoftwareVersion());
- // @formatter:off
- thingDiscovered(DiscoveryResultBuilder.create(thingUID)
- .withThingType(typeUID)
- .withLabel(device.getNameLong())
- .withBridge(bridgeUID)
- .withProperties(properties)
- .withRepresentationProperty(NestDeviceConfiguration.DEVICE_ID)
- .build()
- );
- // @formatter:on
- }
-
- public void addStructureDiscoveryResult(Structure structure, ThingTypeUID typeUID) {
- ThingUID bridgeUID = bridge.getThing().getUID();
- ThingUID thingUID = new ThingUID(typeUID, bridgeUID, structure.getStructureId());
- logger.debug("Discovered {}", thingUID);
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestStructureConfiguration.STRUCTURE_ID, structure.getStructureId());
- // @formatter:off
- thingDiscovered(DiscoveryResultBuilder.create(thingUID)
- .withThingType(THING_TYPE_STRUCTURE)
- .withLabel(structure.getName())
- .withBridge(bridgeUID)
- .withProperties(properties)
- .withRepresentationProperty(NestStructureConfiguration.STRUCTURE_ID)
- .build()
- );
- // @formatter:on
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.exceptions;
-
-/**
- * Will be thrown when the bridge was unable to resolve the Nest redirect URL.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Improve exception handling while sending data
- */
-@SuppressWarnings("serial")
-public class FailedResolvingNestUrlException extends Exception {
- public FailedResolvingNestUrlException(String message) {
- super(message);
- }
-
- public FailedResolvingNestUrlException(String message, Throwable cause) {
- super(message, cause);
- }
-
- public FailedResolvingNestUrlException(Throwable cause) {
- super(cause);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.exceptions;
-
-/**
- * Will be thrown when the bridge was unable to retrieve data.
- *
- * @author Martin van Wingerden - Initial contribution
- * @author Martin van Wingerden - Added more centralized handling of failure when retrieving data
- */
-@SuppressWarnings("serial")
-public class FailedRetrievingNestDataException extends Exception {
-
- public FailedRetrievingNestDataException(String message) {
- super(message);
- }
-
- public FailedRetrievingNestDataException(String message, Throwable cause) {
- super(message, cause);
- }
-
- public FailedRetrievingNestDataException(Throwable cause) {
- super(cause);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.exceptions;
-
-/**
- * Will be thrown when the bridge was unable to send data.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Improve exception handling while sending data
- */
-@SuppressWarnings("serial")
-public class FailedSendingNestDataException extends Exception {
- public FailedSendingNestDataException(String message) {
- super(message);
- }
-
- public FailedSendingNestDataException(String message, Throwable cause) {
- super(message, cause);
- }
-
- public FailedSendingNestDataException(Throwable cause) {
- super(cause);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.exceptions;
-
-/**
- * Will be thrown when there is no valid access token and it was not possible to refresh it
- *
- * @author Martin van Wingerden - Initial contribution
- * @author Martin van Wingerden - Added more centralized handling of invalid access tokens
- */
-@SuppressWarnings("serial")
-public class InvalidAccessTokenException extends Exception {
- public InvalidAccessTokenException(Exception cause) {
- super(cause);
- }
-
- public InvalidAccessTokenException(String message, Throwable cause) {
- super(message, cause);
- }
-
- public InvalidAccessTokenException(String message) {
- super(message);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import java.time.Instant;
-import java.time.ZonedDateTime;
-import java.util.Collection;
-import java.util.Date;
-import java.util.TimeZone;
-import java.util.stream.Collectors;
-
-import javax.measure.Quantity;
-import javax.measure.Unit;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.config.NestDeviceConfiguration;
-import org.openhab.binding.nest.internal.data.NestIdentifiable;
-import org.openhab.binding.nest.internal.listener.NestThingDataListener;
-import org.openhab.binding.nest.internal.rest.NestUpdateRequest;
-import org.openhab.core.library.types.DateTimeType;
-import org.openhab.core.library.types.DecimalType;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.library.types.QuantityType;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.ThingStatusInfo;
-import org.openhab.core.thing.binding.BaseThingHandler;
-import org.openhab.core.types.State;
-import org.openhab.core.types.UnDefType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Deals with the structures on the Nest API, turning them into a thing in openHAB.
- *
- * @author David Bennett - Initial contribution
- * @author Martin van Wingerden - Splitted of NestBaseHandler
- * @author Wouter Born - Add generic update data type
- *
- * @param <T> the type of update data
- */
-@NonNullByDefault
-public abstract class NestBaseHandler<T> extends BaseThingHandler
- implements NestThingDataListener<T>, NestIdentifiable {
- private final Logger logger = LoggerFactory.getLogger(NestBaseHandler.class);
-
- private @Nullable String deviceId;
- private Class<T> dataClass;
-
- NestBaseHandler(Thing thing, Class<T> dataClass) {
- super(thing);
- this.dataClass = dataClass;
- }
-
- @Override
- public void initialize() {
- logger.debug("Initializing handler for {}", getClass().getName());
-
- NestBridgeHandler handler = getNestBridgeHandler();
- if (handler != null) {
- boolean success = handler.addThingDataListener(dataClass, getId(), this);
- logger.debug("Adding {} with ID '{}' as device data listener, result: {}", getClass().getSimpleName(),
- getId(), success);
- } else {
- logger.debug("Unable to add {} with ID '{}' as device data listener because bridge is null",
- getClass().getSimpleName(), getId());
- }
-
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Waiting for refresh");
-
- T lastUpdate = getLastUpdate();
- if (lastUpdate != null) {
- update(null, lastUpdate);
- }
- }
-
- @Override
- public void dispose() {
- NestBridgeHandler handler = getNestBridgeHandler();
- if (handler != null) {
- handler.removeThingDataListener(dataClass, getId(), this);
- }
- }
-
- protected @Nullable T getLastUpdate() {
- NestBridgeHandler handler = getNestBridgeHandler();
- if (handler != null) {
- return handler.getLastUpdate(dataClass, getId());
- }
- return null;
- }
-
- protected void addUpdateRequest(String updatePath, String field, Object value) {
- NestBridgeHandler handler = getNestBridgeHandler();
- if (handler != null) {
- // @formatter:off
- handler.addUpdateRequest(new NestUpdateRequest.Builder()
- .withBasePath(updatePath)
- .withIdentifier(getId())
- .withAdditionalValue(field, value)
- .build());
- // @formatter:on
- }
- }
-
- @Override
- public String getId() {
- return getDeviceId();
- }
-
- protected String getDeviceId() {
- String localDeviceId = deviceId;
- if (localDeviceId == null) {
- localDeviceId = getConfigAs(NestDeviceConfiguration.class).deviceId;
- deviceId = localDeviceId;
- }
- return localDeviceId;
- }
-
- protected @Nullable NestBridgeHandler getNestBridgeHandler() {
- Bridge bridge = getBridge();
- return bridge != null ? (NestBridgeHandler) bridge.getHandler() : null;
- }
-
- protected abstract State getChannelState(ChannelUID channelUID, T data);
-
- protected State getAsDateTimeTypeOrNull(@Nullable Date date) {
- if (date == null) {
- return UnDefType.NULL;
- }
-
- long offsetMillis = TimeZone.getDefault().getOffset(date.getTime());
- Instant instant = date.toInstant().plusMillis(offsetMillis);
- return new DateTimeType(ZonedDateTime.ofInstant(instant, TimeZone.getDefault().toZoneId()));
- }
-
- protected State getAsDecimalTypeOrNull(@Nullable Integer value) {
- return value == null ? UnDefType.NULL : new DecimalType(value);
- }
-
- protected State getAsOnOffTypeOrNull(@Nullable Boolean value) {
- return value == null ? UnDefType.NULL : value ? OnOffType.ON : OnOffType.OFF;
- }
-
- protected <U extends Quantity<U>> State getAsQuantityTypeOrNull(@Nullable Number value, Unit<U> unit) {
- return value == null ? UnDefType.NULL : new QuantityType<>(value, unit);
- }
-
- protected State getAsStringTypeOrNull(@Nullable Object value) {
- return value == null ? UnDefType.NULL : new StringType(value.toString());
- }
-
- protected State getAsStringTypeListOrNull(@Nullable Collection<?> values) {
- return values == null || values.isEmpty() ? UnDefType.NULL
- : new StringType(values.stream().map(v -> v.toString()).collect(Collectors.joining(",")));
- }
-
- protected boolean isNotHandling(NestIdentifiable nestIdentifiable) {
- return !(getId().equals(nestIdentifiable.getId()));
- }
-
- protected void updateLinkedChannels(T oldData, T data) {
- getThing().getChannels().stream().map(c -> c.getUID()).filter(this::isLinked).forEach(channelUID -> {
- State newState = getChannelState(channelUID, data);
- if (oldData == null || !getChannelState(channelUID, oldData).equals(newState)) {
- logger.debug("Updating {}", channelUID);
- updateState(channelUID, newState);
- }
- });
- }
-
- @Override
- public void onNewData(T data) {
- update(null, data);
- }
-
- @Override
- public void onUpdatedData(T oldData, T data) {
- update(oldData, data);
- }
-
- @Override
- public void onMissingData(String nestId) {
- thing.setStatusInfo(
- new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Missing from streaming updates"));
- }
-
- protected abstract void update(T oldData, T data);
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.openhab.binding.nest.internal.NestBindingConstants.JSON_CONTENT_TYPE;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Properties;
-import java.util.Set;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
-
-import javax.ws.rs.client.ClientBuilder;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.NestUtils;
-import org.openhab.binding.nest.internal.config.NestBridgeConfiguration;
-import org.openhab.binding.nest.internal.data.ErrorData;
-import org.openhab.binding.nest.internal.data.NestIdentifiable;
-import org.openhab.binding.nest.internal.data.TopLevelData;
-import org.openhab.binding.nest.internal.exceptions.FailedResolvingNestUrlException;
-import org.openhab.binding.nest.internal.exceptions.FailedSendingNestDataException;
-import org.openhab.binding.nest.internal.exceptions.InvalidAccessTokenException;
-import org.openhab.binding.nest.internal.listener.NestStreamingDataListener;
-import org.openhab.binding.nest.internal.listener.NestThingDataListener;
-import org.openhab.binding.nest.internal.rest.NestAuthorizer;
-import org.openhab.binding.nest.internal.rest.NestStreamingRestClient;
-import org.openhab.binding.nest.internal.rest.NestUpdateRequest;
-import org.openhab.binding.nest.internal.update.NestCompositeUpdateHandler;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.io.net.http.HttpUtil;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.binding.BaseBridgeHandler;
-import org.openhab.core.thing.binding.ThingHandler;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.RefreshType;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * This bridge handler connects to Nest and handles all the API requests. It pulls down the
- * updated data, polls the system and does all the co-ordination with the other handlers
- * to get the data updated to the correct things.
- *
- * @author David Bennett - Initial contribution
- * @author Martin van Wingerden - Use listeners not only for discovery but for all data processing
- * @author Wouter Born - Improve exception and URL redirect handling
- */
-@NonNullByDefault
-public class NestBridgeHandler extends BaseBridgeHandler implements NestStreamingDataListener {
-
- private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
-
- private final Logger logger = LoggerFactory.getLogger(NestBridgeHandler.class);
-
- private final ClientBuilder clientBuilder;
- private final SseEventSourceFactory eventSourceFactory;
- private final List<NestUpdateRequest> nestUpdateRequests = new CopyOnWriteArrayList<>();
- private final NestCompositeUpdateHandler updateHandler = new NestCompositeUpdateHandler(
- this::getPresentThingsNestIds);
-
- private @NonNullByDefault({}) NestAuthorizer authorizer;
- private @NonNullByDefault({}) NestBridgeConfiguration config;
-
- private @Nullable ScheduledFuture<?> initializeJob;
- private @Nullable ScheduledFuture<?> transmitJob;
- private @Nullable NestRedirectUrlSupplier redirectUrlSupplier;
- private @Nullable NestStreamingRestClient streamingRestClient;
-
- /**
- * Creates the bridge handler to connect to Nest.
- *
- * @param bridge The bridge to connect to Nest with.
- */
- public NestBridgeHandler(Bridge bridge, ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory) {
- super(bridge);
- this.clientBuilder = clientBuilder;
- this.eventSourceFactory = eventSourceFactory;
- }
-
- /**
- * Initialize the connection to Nest.
- */
- @Override
- public void initialize() {
- logger.debug("Initializing Nest bridge handler");
-
- config = getConfigAs(NestBridgeConfiguration.class);
- authorizer = new NestAuthorizer(config);
- updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Starting poll query");
-
- initializeJob = scheduler.schedule(() -> {
- try {
- logger.debug("Product ID {}", config.productId);
- logger.debug("Product Secret {}", config.productSecret);
- logger.debug("Pincode {}", config.pincode);
- logger.debug("Access Token {}", getExistingOrNewAccessToken());
- redirectUrlSupplier = createRedirectUrlSupplier();
- restartStreamingUpdates();
- } catch (InvalidAccessTokenException e) {
- logger.debug("Invalid access token", e);
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "Token is invalid and could not be refreshed: " + e.getMessage());
- }
- }, 0, TimeUnit.SECONDS);
-
- logger.debug("Finished initializing Nest bridge handler");
- }
-
- /**
- * Clean up the handler.
- */
- @Override
- public void dispose() {
- logger.debug("Nest bridge disposed");
- stopStreamingUpdates();
-
- ScheduledFuture<?> localInitializeJob = initializeJob;
- if (localInitializeJob != null && !localInitializeJob.isCancelled()) {
- localInitializeJob.cancel(true);
- initializeJob = null;
- }
-
- ScheduledFuture<?> localTransmitJob = transmitJob;
- if (localTransmitJob != null && !localTransmitJob.isCancelled()) {
- localTransmitJob.cancel(true);
- transmitJob = null;
- }
-
- this.authorizer = null;
- this.redirectUrlSupplier = null;
- this.streamingRestClient = null;
- }
-
- public <T> boolean addThingDataListener(Class<T> dataClass, NestThingDataListener<T> listener) {
- return updateHandler.addListener(dataClass, listener);
- }
-
- public <T> boolean addThingDataListener(Class<T> dataClass, String nestId, NestThingDataListener<T> listener) {
- return updateHandler.addListener(dataClass, nestId, listener);
- }
-
- /**
- * Adds the update request into the queue for doing something with, send immediately if the queue is empty.
- */
- public void addUpdateRequest(NestUpdateRequest request) {
- nestUpdateRequests.add(request);
- scheduleTransmitJobForPendingRequests();
- }
-
- protected NestRedirectUrlSupplier createRedirectUrlSupplier() throws InvalidAccessTokenException {
- return new NestRedirectUrlSupplier(getHttpHeaders());
- }
-
- private String getExistingOrNewAccessToken() throws InvalidAccessTokenException {
- String accessToken = config.accessToken;
- if (accessToken == null || accessToken.isEmpty()) {
- accessToken = authorizer.getNewAccessToken();
- config.accessToken = accessToken;
- config.pincode = "";
- // Update and save the access token in the bridge configuration
- Configuration configuration = editConfiguration();
- configuration.put(NestBridgeConfiguration.ACCESS_TOKEN, config.accessToken);
- configuration.put(NestBridgeConfiguration.PINCODE, config.pincode);
- updateConfiguration(configuration);
- logger.debug("Retrieved new access token: {}", config.accessToken);
- return accessToken;
- } else {
- logger.debug("Re-using access token from configuration: {}", accessToken);
- return accessToken;
- }
- }
-
- protected Properties getHttpHeaders() throws InvalidAccessTokenException {
- Properties httpHeaders = new Properties();
- httpHeaders.put("Authorization", "Bearer " + getExistingOrNewAccessToken());
- httpHeaders.put("Content-Type", JSON_CONTENT_TYPE);
- return httpHeaders;
- }
-
- public @Nullable <T> T getLastUpdate(Class<T> dataClass, String nestId) {
- return updateHandler.getLastUpdate(dataClass, nestId);
- }
-
- public <T> List<T> getLastUpdates(Class<T> dataClass) {
- return updateHandler.getLastUpdates(dataClass);
- }
-
- private NestRedirectUrlSupplier getOrCreateRedirectUrlSupplier() throws InvalidAccessTokenException {
- NestRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
- if (localRedirectUrlSupplier == null) {
- localRedirectUrlSupplier = createRedirectUrlSupplier();
- redirectUrlSupplier = localRedirectUrlSupplier;
- }
- return localRedirectUrlSupplier;
- }
-
- private Set<String> getPresentThingsNestIds() {
- Set<String> nestIds = new HashSet<>();
- for (Thing thing : getThing().getThings()) {
- ThingHandler handler = thing.getHandler();
- if (handler != null && thing.getStatusInfo().getStatusDetail() != ThingStatusDetail.GONE) {
- nestIds.add(((NestIdentifiable) handler).getId());
- }
- }
- return nestIds;
- }
-
- /**
- * Handles an incoming command update
- */
- @Override
- public void handleCommand(ChannelUID channelUID, Command command) {
- if (command instanceof RefreshType) {
- logger.debug("Refresh command received");
- updateHandler.resendLastUpdates();
- }
- }
-
- private void jsonToPutUrl(NestUpdateRequest request)
- throws FailedSendingNestDataException, InvalidAccessTokenException, FailedResolvingNestUrlException {
- try {
- NestRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
- if (localRedirectUrlSupplier == null) {
- throw new FailedResolvingNestUrlException("redirectUrlSupplier is null");
- }
-
- String url = localRedirectUrlSupplier.getRedirectUrl() + request.getUpdatePath();
- logger.debug("Putting data to: {}", url);
-
- String jsonContent = NestUtils.toJson(request.getValues());
- logger.debug("PUT content: {}", jsonContent);
-
- ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8));
- String jsonResponse = HttpUtil.executeUrl("PUT", url, getHttpHeaders(), inputStream, JSON_CONTENT_TYPE,
- REQUEST_TIMEOUT);
- logger.debug("PUT response: {}", jsonResponse);
-
- ErrorData error = NestUtils.fromJson(jsonResponse, ErrorData.class);
- if (error.getError() != null && !error.getError().isBlank()) {
- logger.debug("Nest API error: {}", error);
- logger.warn("Nest API error: {}", error.getMessage());
- }
- } catch (IOException e) {
- throw new FailedSendingNestDataException("Failed to send data", e);
- }
- }
-
- @Override
- public void onAuthorizationRevoked(String token) {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "Authorization token revoked: " + token);
- }
-
- @Override
- public void onConnected() {
- updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Streaming data connection established");
- scheduleTransmitJobForPendingRequests();
- }
-
- @Override
- public void onDisconnected() {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Streaming data disconnected");
- }
-
- @Override
- public void onError(String message) {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
- }
-
- @Override
- public void onNewTopLevelData(TopLevelData data) {
- updateHandler.handleUpdate(data);
- updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Receiving streaming data");
- }
-
- public <T> boolean removeThingDataListener(Class<T> dataClass, NestThingDataListener<T> listener) {
- return updateHandler.removeListener(dataClass, listener);
- }
-
- public <T> boolean removeThingDataListener(Class<T> dataClass, String nestId, NestThingDataListener<T> listener) {
- return updateHandler.removeListener(dataClass, nestId, listener);
- }
-
- private void restartStreamingUpdates() {
- synchronized (this) {
- stopStreamingUpdates();
- startStreamingUpdates();
- }
- }
-
- private void scheduleTransmitJobForPendingRequests() {
- ScheduledFuture<?> localTransmitJob = transmitJob;
- if (!nestUpdateRequests.isEmpty() && (localTransmitJob == null || localTransmitJob.isDone())) {
- transmitJob = scheduler.schedule(this::transmitQueue, 0, SECONDS);
- }
- }
-
- private void startStreamingUpdates() {
- synchronized (this) {
- try {
- NestStreamingRestClient localStreamingRestClient = new NestStreamingRestClient(
- getExistingOrNewAccessToken(), clientBuilder, eventSourceFactory,
- getOrCreateRedirectUrlSupplier(), scheduler);
- localStreamingRestClient.addStreamingDataListener(this);
- localStreamingRestClient.start();
-
- streamingRestClient = localStreamingRestClient;
- } catch (InvalidAccessTokenException e) {
- logger.debug("Invalid access token", e);
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "Token is invalid and could not be refreshed: " + e.getMessage());
- }
- }
- }
-
- private void stopStreamingUpdates() {
- NestStreamingRestClient localStreamingRestClient = streamingRestClient;
- if (localStreamingRestClient != null) {
- synchronized (this) {
- localStreamingRestClient.stop();
- localStreamingRestClient.removeStreamingDataListener(this);
- streamingRestClient = null;
- }
- }
- }
-
- private void transmitQueue() {
- if (getThing().getStatus() == ThingStatus.OFFLINE) {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Not transmitting events because bridge is OFFLINE");
- return;
- }
-
- try {
- while (!nestUpdateRequests.isEmpty()) {
- // nestUpdateRequests is a CopyOnWriteArrayList so its iterator does not support remove operations
- NestUpdateRequest request = nestUpdateRequests.get(0);
- jsonToPutUrl(request);
- nestUpdateRequests.remove(request);
- }
- } catch (InvalidAccessTokenException e) {
- logger.debug("Invalid access token", e);
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "Token is invalid and could not be refreshed: " + e.getMessage());
- } catch (FailedResolvingNestUrlException e) {
- logger.debug("Unable to resolve redirect URL", e);
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
- scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
- } catch (FailedSendingNestDataException e) {
- logger.debug("Error sending data", e);
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
- scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
-
- NestRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
- if (localRedirectUrlSupplier != null) {
- localRedirectUrlSupplier.resetCache();
- }
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
-import static org.openhab.core.types.RefreshType.REFRESH;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.nest.internal.data.Camera;
-import org.openhab.binding.nest.internal.data.CameraEvent;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-import org.openhab.core.types.UnDefType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Handles all the updates to the camera as well as handling the commands that send
- * updates to Nest.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Handle channel refresh command
- */
-@NonNullByDefault
-public class NestCameraHandler extends NestBaseHandler<Camera> {
- private final Logger logger = LoggerFactory.getLogger(NestCameraHandler.class);
-
- public NestCameraHandler(Thing thing) {
- super(thing, Camera.class);
- }
-
- @Override
- protected State getChannelState(ChannelUID channelUID, Camera camera) {
- if (channelUID.getId().startsWith(CHANNEL_GROUP_CAMERA_PREFIX)) {
- return getCameraChannelState(channelUID, camera);
- } else if (channelUID.getId().startsWith(CHANNEL_GROUP_LAST_EVENT_PREFIX)) {
- return getLastEventChannelState(channelUID, camera);
- } else {
- logger.error("Unsupported channelId '{}'", channelUID.getId());
- return UnDefType.UNDEF;
- }
- }
-
- protected State getCameraChannelState(ChannelUID channelUID, Camera camera) {
- switch (channelUID.getId()) {
- case CHANNEL_CAMERA_APP_URL:
- return getAsStringTypeOrNull(camera.getAppUrl());
- case CHANNEL_CAMERA_AUDIO_INPUT_ENABLED:
- return getAsOnOffTypeOrNull(camera.isAudioInputEnabled());
- case CHANNEL_CAMERA_LAST_ONLINE_CHANGE:
- return getAsDateTimeTypeOrNull(camera.getLastIsOnlineChange());
- case CHANNEL_CAMERA_PUBLIC_SHARE_ENABLED:
- return getAsOnOffTypeOrNull(camera.isPublicShareEnabled());
- case CHANNEL_CAMERA_PUBLIC_SHARE_URL:
- return getAsStringTypeOrNull(camera.getPublicShareUrl());
- case CHANNEL_CAMERA_SNAPSHOT_URL:
- return getAsStringTypeOrNull(camera.getSnapshotUrl());
- case CHANNEL_CAMERA_STREAMING:
- return getAsOnOffTypeOrNull(camera.isStreaming());
- case CHANNEL_CAMERA_VIDEO_HISTORY_ENABLED:
- return getAsOnOffTypeOrNull(camera.isVideoHistoryEnabled());
- case CHANNEL_CAMERA_WEB_URL:
- return getAsStringTypeOrNull(camera.getWebUrl());
- default:
- logger.error("Unsupported channelId '{}'", channelUID.getId());
- return UnDefType.UNDEF;
- }
- }
-
- protected State getLastEventChannelState(ChannelUID channelUID, Camera camera) {
- CameraEvent lastEvent = camera.getLastEvent();
- if (lastEvent == null) {
- return UnDefType.NULL;
- }
-
- switch (channelUID.getId()) {
- case CHANNEL_LAST_EVENT_ACTIVITY_ZONES:
- return getAsStringTypeListOrNull(lastEvent.getActivityZones());
- case CHANNEL_LAST_EVENT_ANIMATED_IMAGE_URL:
- return getAsStringTypeOrNull(lastEvent.getAnimatedImageUrl());
- case CHANNEL_LAST_EVENT_APP_URL:
- return getAsStringTypeOrNull(lastEvent.getAppUrl());
- case CHANNEL_LAST_EVENT_END_TIME:
- return getAsDateTimeTypeOrNull(lastEvent.getEndTime());
- case CHANNEL_LAST_EVENT_HAS_MOTION:
- return getAsOnOffTypeOrNull(lastEvent.isHasMotion());
- case CHANNEL_LAST_EVENT_HAS_PERSON:
- return getAsOnOffTypeOrNull(lastEvent.isHasPerson());
- case CHANNEL_LAST_EVENT_HAS_SOUND:
- return getAsOnOffTypeOrNull(lastEvent.isHasSound());
- case CHANNEL_LAST_EVENT_IMAGE_URL:
- return getAsStringTypeOrNull(lastEvent.getImageUrl());
- case CHANNEL_LAST_EVENT_START_TIME:
- return getAsDateTimeTypeOrNull(lastEvent.getStartTime());
- case CHANNEL_LAST_EVENT_URLS_EXPIRE_TIME:
- return getAsDateTimeTypeOrNull(lastEvent.getUrlsExpireTime());
- case CHANNEL_LAST_EVENT_WEB_URL:
- return getAsStringTypeOrNull(lastEvent.getWebUrl());
- default:
- logger.error("Unsupported channelId '{}'", channelUID.getId());
- return UnDefType.UNDEF;
- }
- }
-
- @Override
- public void handleCommand(ChannelUID channelUID, Command command) {
- if (REFRESH.equals(command)) {
- Camera lastUpdate = getLastUpdate();
- if (lastUpdate != null) {
- updateState(channelUID, getChannelState(channelUID, lastUpdate));
- }
- } else if (CHANNEL_CAMERA_STREAMING.equals(channelUID.getId())) {
- // Change the mode.
- if (command instanceof OnOffType) {
- // Set the mode to be the cmd value.
- addUpdateRequest("is_streaming", command == OnOffType.ON);
- }
- }
- }
-
- private void addUpdateRequest(String field, Object value) {
- addUpdateRequest(NEST_CAMERA_UPDATE_PATH, field, value);
- }
-
- @Override
- protected void update(Camera oldCamera, Camera camera) {
- logger.debug("Updating {}", getThing().getUID());
-
- updateLinkedChannels(oldCamera, camera);
- updateProperty(PROPERTY_FIRMWARE_VERSION, camera.getSoftwareVersion());
-
- ThingStatus newStatus = camera.isOnline() == null ? ThingStatus.UNKNOWN
- : camera.isOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
- if (newStatus != thing.getStatus()) {
- updateStatus(newStatus);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import java.util.Properties;
-import java.util.concurrent.TimeUnit;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.api.ContentResponse;
-import org.eclipse.jetty.client.api.Request;
-import org.eclipse.jetty.http.HttpHeader;
-import org.eclipse.jetty.http.HttpMethod;
-import org.eclipse.jetty.http.HttpStatus;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.openhab.binding.nest.internal.NestBindingConstants;
-import org.openhab.binding.nest.internal.exceptions.FailedResolvingNestUrlException;
-import org.openhab.core.io.net.http.HttpUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Supplies resolved redirect URLs of {@link NestBindingConstants#NEST_URL} so they can be used with HTTP clients that
- * do not pass Authorization headers after redirects like the Jetty client used by {@link HttpUtil}.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Extract resolving redirect URL from NestBridgeHandler into NestRedirectUrlSupplier
- */
-@NonNullByDefault
-public class NestRedirectUrlSupplier {
-
- private final Logger logger = LoggerFactory.getLogger(NestRedirectUrlSupplier.class);
-
- protected String cachedUrl = "";
-
- protected Properties httpHeaders;
-
- public NestRedirectUrlSupplier(Properties httpHeaders) {
- this.httpHeaders = httpHeaders;
- }
-
- public String getRedirectUrl() throws FailedResolvingNestUrlException {
- if (cachedUrl.isEmpty()) {
- cachedUrl = resolveRedirectUrl();
- }
- return cachedUrl;
- }
-
- public void resetCache() {
- cachedUrl = "";
- }
-
- /**
- * Resolves the redirect URL for calls using the {@link NestBindingConstants#NEST_URL}.
- *
- * The Jetty client used by {@link HttpUtil} will not pass the Authorization header after a redirect resulting in
- * "401 Unauthorized error" issues.
- *
- * Note that this workaround currently does not use any configured proxy like {@link HttpUtil} does.
- *
- * @see https://developers.nest.com/documentation/cloud/how-to-handle-redirects
- */
- private String resolveRedirectUrl() throws FailedResolvingNestUrlException {
- HttpClient httpClient = new HttpClient(new SslContextFactory.Client());
- httpClient.setFollowRedirects(false);
-
- Request request = httpClient.newRequest(NestBindingConstants.NEST_URL).method(HttpMethod.GET).timeout(30,
- TimeUnit.SECONDS);
- for (String httpHeaderKey : httpHeaders.stringPropertyNames()) {
- request.header(httpHeaderKey, httpHeaders.getProperty(httpHeaderKey));
- }
-
- ContentResponse response;
- try {
- httpClient.start();
- response = request.send();
- httpClient.stop();
- } catch (Exception e) {
- throw new FailedResolvingNestUrlException("Failed to resolve redirect URL: " + e.getMessage(), e);
- }
-
- int status = response.getStatus();
- String redirectUrl = response.getHeaders().get(HttpHeader.LOCATION);
-
- if (status != HttpStatus.TEMPORARY_REDIRECT_307) {
- logger.debug("Redirect status: {}", status);
- logger.debug("Redirect response: {}", response.getContentAsString());
- throw new FailedResolvingNestUrlException("Failed to get redirect URL, expected status "
- + HttpStatus.TEMPORARY_REDIRECT_307 + " but was " + status);
- } else if (redirectUrl == null || redirectUrl.isEmpty()) {
- throw new FailedResolvingNestUrlException("Redirect URL is empty");
- }
-
- redirectUrl = redirectUrl.endsWith("/") ? redirectUrl.substring(0, redirectUrl.length() - 1) : redirectUrl;
- logger.debug("Redirect URL: {}", redirectUrl);
- return redirectUrl;
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
-import static org.openhab.core.types.RefreshType.REFRESH;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.nest.internal.data.SmokeDetector;
-import org.openhab.binding.nest.internal.data.SmokeDetector.BatteryHealth;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-import org.openhab.core.types.UnDefType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The smoke detector handler, it handles the data from Nest for the smoke detector.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Handle channel refresh command
- */
-@NonNullByDefault
-public class NestSmokeDetectorHandler extends NestBaseHandler<SmokeDetector> {
- private final Logger logger = LoggerFactory.getLogger(NestSmokeDetectorHandler.class);
-
- public NestSmokeDetectorHandler(Thing thing) {
- super(thing, SmokeDetector.class);
- }
-
- @Override
- protected State getChannelState(ChannelUID channelUID, SmokeDetector smokeDetector) {
- switch (channelUID.getId()) {
- case CHANNEL_CO_ALARM_STATE:
- return getAsStringTypeOrNull(smokeDetector.getCoAlarmState());
- case CHANNEL_LAST_CONNECTION:
- return getAsDateTimeTypeOrNull(smokeDetector.getLastConnection());
- case CHANNEL_LAST_MANUAL_TEST_TIME:
- return getAsDateTimeTypeOrNull(smokeDetector.getLastManualTestTime());
- case CHANNEL_LOW_BATTERY:
- return getAsOnOffTypeOrNull(smokeDetector.getBatteryHealth() == null ? null
- : smokeDetector.getBatteryHealth() == BatteryHealth.REPLACE);
- case CHANNEL_MANUAL_TEST_ACTIVE:
- return getAsOnOffTypeOrNull(smokeDetector.isManualTestActive());
- case CHANNEL_SMOKE_ALARM_STATE:
- return getAsStringTypeOrNull(smokeDetector.getSmokeAlarmState());
- case CHANNEL_UI_COLOR_STATE:
- return getAsStringTypeOrNull(smokeDetector.getUiColorState());
- default:
- logger.error("Unsupported channelId '{}'", channelUID.getId());
- return UnDefType.UNDEF;
- }
- }
-
- /**
- * Handles any incoming command requests.
- */
- @Override
- public void handleCommand(ChannelUID channelUID, Command command) {
- if (REFRESH.equals(command)) {
- SmokeDetector lastUpdate = getLastUpdate();
- if (lastUpdate != null) {
- updateState(channelUID, getChannelState(channelUID, lastUpdate));
- }
- }
- }
-
- @Override
- protected void update(SmokeDetector oldSmokeDetector, SmokeDetector smokeDetector) {
- logger.debug("Updating {}", getThing().getUID());
-
- updateLinkedChannels(oldSmokeDetector, smokeDetector);
- updateProperty(PROPERTY_FIRMWARE_VERSION, smokeDetector.getSoftwareVersion());
-
- ThingStatus newStatus = smokeDetector.isOnline() == null ? ThingStatus.UNKNOWN
- : smokeDetector.isOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
- if (newStatus != thing.getStatus()) {
- updateStatus(newStatus);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.core.types.RefreshType.REFRESH;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.config.NestStructureConfiguration;
-import org.openhab.binding.nest.internal.data.Structure;
-import org.openhab.binding.nest.internal.data.Structure.HomeAwayState;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-import org.openhab.core.types.UnDefType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Deals with the structures on the Nest API, turning them into a thing in openHAB.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Handle channel refresh command
- */
-@NonNullByDefault
-public class NestStructureHandler extends NestBaseHandler<Structure> {
- private final Logger logger = LoggerFactory.getLogger(NestStructureHandler.class);
-
- private @Nullable String structureId;
-
- public NestStructureHandler(Thing thing) {
- super(thing, Structure.class);
- }
-
- @Override
- protected State getChannelState(ChannelUID channelUID, Structure structure) {
- switch (channelUID.getId()) {
- case CHANNEL_AWAY:
- return getAsStringTypeOrNull(structure.getAway());
- case CHANNEL_CO_ALARM_STATE:
- return getAsStringTypeOrNull(structure.getCoAlarmState());
- case CHANNEL_COUNTRY_CODE:
- return getAsStringTypeOrNull(structure.getCountryCode());
- case CHANNEL_ETA_BEGIN:
- return getAsDateTimeTypeOrNull(structure.getEtaBegin());
- case CHANNEL_PEAK_PERIOD_END_TIME:
- return getAsDateTimeTypeOrNull(structure.getPeakPeriodEndTime());
- case CHANNEL_PEAK_PERIOD_START_TIME:
- return getAsDateTimeTypeOrNull(structure.getPeakPeriodStartTime());
- case CHANNEL_POSTAL_CODE:
- return getAsStringTypeOrNull(structure.getPostalCode());
- case CHANNEL_RUSH_HOUR_REWARDS_ENROLLMENT:
- return getAsOnOffTypeOrNull(structure.isRhrEnrollment());
- case CHANNEL_SECURITY_STATE:
- return getAsStringTypeOrNull(structure.getWwnSecurityState());
- case CHANNEL_SMOKE_ALARM_STATE:
- return getAsStringTypeOrNull(structure.getSmokeAlarmState());
- case CHANNEL_TIME_ZONE:
- return getAsStringTypeOrNull(structure.getTimeZone());
- default:
- logger.error("Unsupported channelId '{}'", channelUID.getId());
- return UnDefType.UNDEF;
- }
- }
-
- @Override
- public String getId() {
- return getStructureId();
- }
-
- private String getStructureId() {
- String localStructureId = structureId;
- if (localStructureId == null) {
- localStructureId = getConfigAs(NestStructureConfiguration.class).structureId;
- structureId = localStructureId;
- }
- return localStructureId;
- }
-
- /**
- * Handles updating the details on this structure by sending the request all the way
- * to Nest.
- *
- * @param channelUID the channel to update
- * @param command the command to apply
- */
- @Override
- public void handleCommand(ChannelUID channelUID, Command command) {
- if (REFRESH.equals(command)) {
- Structure lastUpdate = getLastUpdate();
- if (lastUpdate != null) {
- updateState(channelUID, getChannelState(channelUID, lastUpdate));
- }
- } else if (CHANNEL_AWAY.equals(channelUID.getId())) {
- // Change the home/away state.
- if (command instanceof StringType) {
- StringType cmd = (StringType) command;
- // Set the mode to be the cmd value.
- addUpdateRequest(NEST_STRUCTURE_UPDATE_PATH, "away", HomeAwayState.valueOf(cmd.toString()));
- }
- }
- }
-
- @Override
- protected void update(Structure oldStructure, Structure structure) {
- logger.debug("Updating {}", getThing().getUID());
-
- updateLinkedChannels(oldStructure, structure);
-
- if (ThingStatus.ONLINE != thing.getStatus()) {
- updateStatus(ThingStatus.ONLINE);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.handler;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.core.library.unit.SIUnits.CELSIUS;
-import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
-import static org.openhab.core.types.RefreshType.REFRESH;
-
-import java.math.BigDecimal;
-import java.math.RoundingMode;
-
-import javax.measure.Unit;
-import javax.measure.quantity.Temperature;
-import javax.measure.quantity.Time;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.data.Thermostat;
-import org.openhab.binding.nest.internal.data.Thermostat.Mode;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.library.types.QuantityType;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.library.unit.Units;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-import org.openhab.core.types.UnDefType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The {@link NestThermostatHandler} is responsible for handling commands, which are
- * sent to one of the channels for the thermostat.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Handle channel refresh command
- */
-@NonNullByDefault
-public class NestThermostatHandler extends NestBaseHandler<Thermostat> {
- private final Logger logger = LoggerFactory.getLogger(NestThermostatHandler.class);
-
- public NestThermostatHandler(Thing thing) {
- super(thing, Thermostat.class);
- }
-
- @Override
- protected State getChannelState(ChannelUID channelUID, Thermostat thermostat) {
- switch (channelUID.getId()) {
- case CHANNEL_CAN_COOL:
- return getAsOnOffTypeOrNull(thermostat.isCanCool());
- case CHANNEL_CAN_HEAT:
- return getAsOnOffTypeOrNull(thermostat.isCanHeat());
- case CHANNEL_ECO_MAX_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getEcoTemperatureHigh(), thermostat.getTemperatureUnit());
- case CHANNEL_ECO_MIN_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getEcoTemperatureLow(), thermostat.getTemperatureUnit());
- case CHANNEL_FAN_TIMER_ACTIVE:
- return getAsOnOffTypeOrNull(thermostat.isFanTimerActive());
- case CHANNEL_FAN_TIMER_DURATION:
- return getAsQuantityTypeOrNull(thermostat.getFanTimerDuration(), Units.MINUTE);
- case CHANNEL_FAN_TIMER_TIMEOUT:
- return getAsDateTimeTypeOrNull(thermostat.getFanTimerTimeout());
- case CHANNEL_HAS_FAN:
- return getAsOnOffTypeOrNull(thermostat.isHasFan());
- case CHANNEL_HAS_LEAF:
- return getAsOnOffTypeOrNull(thermostat.isHasLeaf());
- case CHANNEL_HUMIDITY:
- return getAsQuantityTypeOrNull(thermostat.getHumidity(), Units.PERCENT);
- case CHANNEL_LAST_CONNECTION:
- return getAsDateTimeTypeOrNull(thermostat.getLastConnection());
- case CHANNEL_LOCKED:
- return getAsOnOffTypeOrNull(thermostat.isLocked());
- case CHANNEL_LOCKED_MAX_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getLockedTempMax(), thermostat.getTemperatureUnit());
- case CHANNEL_LOCKED_MIN_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getLockedTempMin(), thermostat.getTemperatureUnit());
- case CHANNEL_MAX_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getTargetTemperatureHigh(), thermostat.getTemperatureUnit());
- case CHANNEL_MIN_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getTargetTemperatureLow(), thermostat.getTemperatureUnit());
- case CHANNEL_MODE:
- return getAsStringTypeOrNull(thermostat.getMode());
- case CHANNEL_PREVIOUS_MODE:
- Mode previousMode = thermostat.getPreviousHvacMode() != null ? thermostat.getPreviousHvacMode()
- : thermostat.getMode();
- return getAsStringTypeOrNull(previousMode);
- case CHANNEL_STATE:
- return getAsStringTypeOrNull(thermostat.getHvacState());
- case CHANNEL_SET_POINT:
- return getAsQuantityTypeOrNull(thermostat.getTargetTemperature(), thermostat.getTemperatureUnit());
- case CHANNEL_SUNLIGHT_CORRECTION_ACTIVE:
- return getAsOnOffTypeOrNull(thermostat.isSunlightCorrectionActive());
- case CHANNEL_SUNLIGHT_CORRECTION_ENABLED:
- return getAsOnOffTypeOrNull(thermostat.isSunlightCorrectionEnabled());
- case CHANNEL_TEMPERATURE:
- return getAsQuantityTypeOrNull(thermostat.getAmbientTemperature(), thermostat.getTemperatureUnit());
- case CHANNEL_TIME_TO_TARGET:
- return getAsQuantityTypeOrNull(thermostat.getTimeToTarget(), Units.MINUTE);
- case CHANNEL_USING_EMERGENCY_HEAT:
- return getAsOnOffTypeOrNull(thermostat.isUsingEmergencyHeat());
- default:
- logger.error("Unsupported channelId '{}'", channelUID.getId());
- return UnDefType.UNDEF;
- }
- }
-
- /**
- * Handle the command to do things to the thermostat, this will change the
- * value of a channel by sending the request to Nest.
- */
- @Override
- @SuppressWarnings("unchecked")
- public void handleCommand(ChannelUID channelUID, Command command) {
- if (REFRESH.equals(command)) {
- Thermostat lastUpdate = getLastUpdate();
- if (lastUpdate != null) {
- updateState(channelUID, getChannelState(channelUID, lastUpdate));
- }
- } else if (CHANNEL_FAN_TIMER_ACTIVE.equals(channelUID.getId())) {
- if (command instanceof OnOffType) {
- // Update fan timer active to the command value
- addUpdateRequest("fan_timer_active", command == OnOffType.ON);
- }
- } else if (CHANNEL_FAN_TIMER_DURATION.equals(channelUID.getId())) {
- if (command instanceof QuantityType) {
- // Update fan timer duration to the command value
- QuantityType<Time> minuteQuantity = ((QuantityType<Time>) command).toUnit(Units.MINUTE);
- if (minuteQuantity != null) {
- addUpdateRequest("fan_timer_duration", minuteQuantity.intValue());
- }
- }
- } else if (CHANNEL_MAX_SET_POINT.equals(channelUID.getId())) {
- if (command instanceof QuantityType) {
- // Update maximum set point to the command value
- addTemperatureUpdateRequest("target_temperature_high_c", "target_temperature_high_f",
- (QuantityType<Temperature>) command);
- }
- } else if (CHANNEL_MIN_SET_POINT.equals(channelUID.getId())) {
- if (command instanceof QuantityType) {
- // Update minimum set point to the command value
- addTemperatureUpdateRequest("target_temperature_low_c", "target_temperature_low_f",
- (QuantityType<Temperature>) command);
- }
- } else if (CHANNEL_MODE.equals(channelUID.getId())) {
- if (command instanceof StringType) {
- // Update the HVAC mode to the command value
- addUpdateRequest("hvac_mode", Mode.valueOf(((StringType) command).toString()));
- }
- } else if (CHANNEL_SET_POINT.equals(channelUID.getId())) {
- if (command instanceof QuantityType) {
- // Update set point to the command value
- addTemperatureUpdateRequest("target_temperature_c", "target_temperature_f",
- (QuantityType<Temperature>) command);
- }
- }
- }
-
- private void addUpdateRequest(String field, Object value) {
- addUpdateRequest(NEST_THERMOSTAT_UPDATE_PATH, field, value);
- }
-
- private void addTemperatureUpdateRequest(String celsiusField, String fahrenheitField,
- QuantityType<Temperature> quantity) {
- Unit<Temperature> unit = getTemperatureUnit(quantity.getUnit());
- BigDecimal value = quantityToRoundedTemperature(quantity, unit);
- if (value != null) {
- addUpdateRequest(NEST_THERMOSTAT_UPDATE_PATH, unit == CELSIUS ? celsiusField : fahrenheitField, value);
- }
- }
-
- private Unit<Temperature> getTemperatureUnit(Unit<Temperature> fallbackUnit) {
- Thermostat lastUpdate = getLastUpdate();
- if (lastUpdate != null && lastUpdate.getTemperatureUnit() != null) {
- return lastUpdate.getTemperatureUnit();
- }
-
- return fallbackUnit;
- }
-
- private @Nullable BigDecimal quantityToRoundedTemperature(QuantityType<Temperature> quantity,
- Unit<Temperature> unit) throws IllegalArgumentException {
- QuantityType<Temperature> temparatureQuantity = quantity.toUnit(unit);
- if (temparatureQuantity == null) {
- return null;
- }
-
- BigDecimal value = temparatureQuantity.toBigDecimal();
- BigDecimal increment = CELSIUS == unit ? new BigDecimal("0.5") : new BigDecimal("1");
- BigDecimal divisor = value.divide(increment, 0, RoundingMode.HALF_UP);
- return divisor.multiply(increment);
- }
-
- @Override
- protected void update(Thermostat oldThermostat, Thermostat thermostat) {
- logger.debug("Updating {}", getThing().getUID());
-
- updateLinkedChannels(oldThermostat, thermostat);
- updateProperty(PROPERTY_FIRMWARE_VERSION, thermostat.getSoftwareVersion());
-
- ThingStatus newStatus = thermostat.isOnline() == null ? ThingStatus.UNKNOWN
- : thermostat.isOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
- if (newStatus != thing.getStatus()) {
- updateStatus(newStatus);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.listener;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.nest.internal.data.TopLevelData;
-import org.openhab.binding.nest.internal.rest.NestStreamingRestClient;
-
-/**
- * Interface for listeners of events generated by the {@link NestStreamingRestClient}.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Replace polling with REST streaming
- */
-@NonNullByDefault
-public interface NestStreamingDataListener {
-
- /**
- * Authorization has been revoked for a token.
- */
- void onAuthorizationRevoked(String token);
-
- /**
- * The client successfully established a connection.
- */
- void onConnected();
-
- /**
- * The client was disconnected.
- */
- void onDisconnected();
-
- /**
- * An error message was published.
- */
- void onError(String message);
-
- /**
- * Initial {@link TopLevelData} or an update is sent.
- */
- void onNewTopLevelData(TopLevelData data);
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.listener;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Used to track incoming data for Nest things.
- *
- * @author Wouter Born - Initial contribution
- */
-@NonNullByDefault
-public interface NestThingDataListener<T> {
-
- /**
- * An initial value for the data was received or the value is send again due to a refresh.
- *
- * @param data the data
- */
- void onNewData(T data);
-
- /**
- * Existing data was updated to a new value.
- *
- * @param oldData the previous value
- * @param data the current value
- */
- void onUpdatedData(T oldData, T data);
-
- /**
- * A Nest thing which previously had data is missing. E.g. it was removed from the account.
- *
- * @param nestId identifies the Nest thing
- */
- void onMissingData(String nestId);
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.rest;
-
-import java.io.IOException;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.nest.internal.NestBindingConstants;
-import org.openhab.binding.nest.internal.NestUtils;
-import org.openhab.binding.nest.internal.config.NestBridgeConfiguration;
-import org.openhab.binding.nest.internal.data.AccessTokenData;
-import org.openhab.binding.nest.internal.exceptions.InvalidAccessTokenException;
-import org.openhab.core.io.net.http.HttpUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Retrieves the Nest access token using the OAuth 2.0 protocol using pin-based authorization.
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Improve exception handling
- */
-@NonNullByDefault
-public class NestAuthorizer {
- private final Logger logger = LoggerFactory.getLogger(NestAuthorizer.class);
-
- private final NestBridgeConfiguration config;
-
- /**
- * Create the helper class for the Nest access token. Also creates the folder
- * to put the access token data in if it does not already exist.
- *
- * @param config The configuration to use for the token
- */
- public NestAuthorizer(NestBridgeConfiguration config) {
- this.config = config;
- }
-
- /**
- * Get the current access token, refreshing if needed.
- *
- * @throws InvalidAccessTokenException thrown when the access token is invalid and could not be refreshed
- */
- public String getNewAccessToken() throws InvalidAccessTokenException {
- try {
- String pincode = config.pincode;
- if (pincode == null || pincode.isBlank()) {
- throw new InvalidAccessTokenException("Pincode is empty");
- }
-
- // @formatter:off
- StringBuilder urlBuilder = new StringBuilder(NestBindingConstants.NEST_ACCESS_TOKEN_URL)
- .append("?client_id=")
- .append(config.productId)
- .append("&client_secret=")
- .append(config.productSecret)
- .append("&code=")
- .append(pincode)
- .append("&grant_type=authorization_code");
- // @formatter:on
-
- logger.debug("Requesting access token from URL: {}", urlBuilder);
-
- String responseContentAsString = HttpUtil.executeUrl("POST", urlBuilder.toString(), null, null,
- "application/x-www-form-urlencoded", 10_000);
-
- AccessTokenData data = NestUtils.fromJson(responseContentAsString, AccessTokenData.class);
- logger.debug("Received: {}", data);
-
- String accessToken = data.getAccessToken();
- if (accessToken == null || accessToken.isBlank()) {
- throw new InvalidAccessTokenException("Pincode to obtain access token is already used or invalid)");
- }
- return accessToken;
- } catch (IOException e) {
- throw new InvalidAccessTokenException("Access token request failed", e);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.rest;
-
-import java.io.IOException;
-
-import javax.ws.rs.client.ClientRequestContext;
-import javax.ws.rs.client.ClientRequestFilter;
-import javax.ws.rs.core.HttpHeaders;
-import javax.ws.rs.core.MultivaluedMap;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- * Inserts Authorization and Cache-Control headers for requests on the streaming REST API.
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Replace polling with REST streaming
- */
-@NonNullByDefault
-public class NestStreamingRequestFilter implements ClientRequestFilter {
- private final String accessToken;
-
- public NestStreamingRequestFilter(String accessToken) {
- this.accessToken = accessToken;
- }
-
- @Override
- public void filter(@Nullable ClientRequestContext requestContext) throws IOException {
- if (requestContext != null) {
- MultivaluedMap<String, Object> headers = requestContext.getHeaders();
- headers.putSingle(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
- headers.putSingle(HttpHeaders.CACHE_CONTROL, "no-cache");
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.rest;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.KEEP_ALIVE_MILLIS;
-
-import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
-
-import javax.ws.rs.client.Client;
-import javax.ws.rs.client.ClientBuilder;
-import javax.ws.rs.sse.InboundSseEvent;
-import javax.ws.rs.sse.SseEventSource;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.NestUtils;
-import org.openhab.binding.nest.internal.data.TopLevelData;
-import org.openhab.binding.nest.internal.data.TopLevelStreamingData;
-import org.openhab.binding.nest.internal.exceptions.FailedResolvingNestUrlException;
-import org.openhab.binding.nest.internal.handler.NestRedirectUrlSupplier;
-import org.openhab.binding.nest.internal.listener.NestStreamingDataListener;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * A client that generates events based on Nest streaming REST API Server-Sent Events (SSE).
- *
- * @author Wouter Born - Initial contribution
- * @author Wouter Born - Replace polling with REST streaming
- */
-@NonNullByDefault
-public class NestStreamingRestClient {
-
- // Assume connection timeout when 2 keep alive message should have been received
- private static final long CONNECTION_TIMEOUT_MILLIS = 2 * KEEP_ALIVE_MILLIS + KEEP_ALIVE_MILLIS / 2;
-
- public static final String AUTH_REVOKED = "auth_revoked";
- public static final String ERROR = "error";
- public static final String KEEP_ALIVE = "keep-alive";
- public static final String OPEN = "open";
- public static final String PUT = "put";
-
- private final Logger logger = LoggerFactory.getLogger(NestStreamingRestClient.class);
-
- private final String accessToken;
- private final ClientBuilder clientBuilder;
- private final SseEventSourceFactory eventSourceFactory;
- private final NestRedirectUrlSupplier redirectUrlSupplier;
- private final ScheduledExecutorService scheduler;
-
- private final Object startStopLock = new Object();
- private final List<NestStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
-
- private @Nullable ScheduledFuture<?> checkConnectionJob;
- private boolean connected;
- private @Nullable SseEventSource eventSource;
- private long lastEventTimestamp;
- private @Nullable TopLevelData lastReceivedTopLevelData;
-
- public NestStreamingRestClient(String accessToken, ClientBuilder clientBuilder,
- SseEventSourceFactory eventSourceFactory, NestRedirectUrlSupplier redirectUrlSupplier,
- ScheduledExecutorService scheduler) {
- this.accessToken = accessToken;
- this.clientBuilder = clientBuilder;
- this.eventSourceFactory = eventSourceFactory;
- this.redirectUrlSupplier = redirectUrlSupplier;
- this.scheduler = scheduler;
- }
-
- private SseEventSource createEventSource() throws FailedResolvingNestUrlException {
- Client client = clientBuilder.register(new NestStreamingRequestFilter(accessToken)).build();
- SseEventSource eventSource = eventSourceFactory.newSource(client.target(redirectUrlSupplier.getRedirectUrl()));
- eventSource.register(this::onEvent, this::onError);
- return eventSource;
- }
-
- private void checkConnection() {
- long millisSinceLastEvent = System.currentTimeMillis() - lastEventTimestamp;
- if (millisSinceLastEvent > CONNECTION_TIMEOUT_MILLIS) {
- logger.debug("Check: Disconnected from streaming events, millisSinceLastEvent={}", millisSinceLastEvent);
- synchronized (startStopLock) {
- stopCheckConnectionJob(false);
- if (connected) {
- connected = false;
- listeners.forEach(listener -> listener.onDisconnected());
- }
- redirectUrlSupplier.resetCache();
- reopenEventSource();
- startCheckConnectionJob();
- }
- } else {
- logger.debug("Check: Receiving streaming events, millisSinceLastEvent={}", millisSinceLastEvent);
- }
- }
-
- /**
- * Closes the existing EventSource and opens a new EventSource as workaround when the EventSource fails to reconnect
- * itself.
- */
- private void reopenEventSource() {
- try {
- logger.debug("Reopening EventSource");
- closeEventSource(10, TimeUnit.SECONDS);
-
- logger.debug("Opening new EventSource");
- SseEventSource localEventSource = createEventSource();
- localEventSource.open();
-
- eventSource = localEventSource;
- } catch (FailedResolvingNestUrlException e) {
- logger.debug("Failed to resolve Nest redirect URL while opening new EventSource");
- }
- }
-
- public void start() {
- synchronized (startStopLock) {
- logger.debug("Opening EventSource and starting checkConnection job");
- reopenEventSource();
- startCheckConnectionJob();
- logger.debug("Started");
- }
- }
-
- public void stop() {
- synchronized (startStopLock) {
- logger.debug("Closing EventSource and stopping checkConnection job");
- stopCheckConnectionJob(true);
- closeEventSource(0, TimeUnit.SECONDS);
- logger.debug("Stopped");
- }
- }
-
- private void closeEventSource(long timeout, TimeUnit timeoutUnit) {
- SseEventSource localEventSource = eventSource;
- if (localEventSource != null) {
- if (!localEventSource.isOpen()) {
- logger.debug("Existing EventSource is already closed");
- } else if (localEventSource.close(timeout, timeoutUnit)) {
- logger.debug("Succesfully closed existing EventSource");
- } else {
- logger.debug("Failed to close existing EventSource");
- }
- eventSource = null;
- }
- }
-
- private void startCheckConnectionJob() {
- ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
- if (localCheckConnectionJob == null || localCheckConnectionJob.isCancelled()) {
- checkConnectionJob = scheduler.scheduleWithFixedDelay(this::checkConnection, CONNECTION_TIMEOUT_MILLIS,
- KEEP_ALIVE_MILLIS, TimeUnit.MILLISECONDS);
- }
- }
-
- private void stopCheckConnectionJob(boolean mayInterruptIfRunning) {
- ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
- if (localCheckConnectionJob != null && !localCheckConnectionJob.isCancelled()) {
- localCheckConnectionJob.cancel(mayInterruptIfRunning);
- checkConnectionJob = null;
- }
- }
-
- public boolean addStreamingDataListener(NestStreamingDataListener listener) {
- return listeners.add(listener);
- }
-
- public boolean removeStreamingDataListener(NestStreamingDataListener listener) {
- return listeners.remove(listener);
- }
-
- public @Nullable TopLevelData getLastReceivedTopLevelData() {
- return lastReceivedTopLevelData;
- }
-
- private void onEvent(InboundSseEvent inboundEvent) {
- try {
- lastEventTimestamp = System.currentTimeMillis();
-
- String name = inboundEvent.getName();
- String data = inboundEvent.readData();
-
- logger.debug("Received '{}' event, data: {}", name, data);
-
- if (!connected) {
- logger.debug("Connected to streaming events");
- connected = true;
- listeners.forEach(listener -> listener.onConnected());
- }
-
- if (AUTH_REVOKED.equals(name)) {
- logger.debug("API authorization has been revoked for access token: {}", data);
- listeners.forEach(listener -> listener.onAuthorizationRevoked(data));
- } else if (ERROR.equals(name)) {
- logger.warn("Error occurred: {}", data);
- listeners.forEach(listener -> listener.onError(data));
- } else if (KEEP_ALIVE.equals(name)) {
- logger.debug("Received message to keep connection alive");
- } else if (OPEN.equals(name)) {
- logger.debug("Event stream opened");
- } else if (PUT.equals(name)) {
- logger.debug("Data has changed (or initial data sent)");
- TopLevelData topLevelData = NestUtils.fromJson(data, TopLevelStreamingData.class).getData();
- lastReceivedTopLevelData = topLevelData;
- listeners.forEach(listener -> listener.onNewTopLevelData(topLevelData));
- } else {
- logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
- }
- } catch (Exception e) {
- // catch exceptions here otherwise they will be swallowed by the implementation
- logger.warn("An exception occurred while processing the inbound event", e);
- }
- }
-
- private void onError(Throwable error) {
- logger.debug("Error occurred while receiving events", error);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.rest;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Contains the data needed to do an update request back to Nest.
- *
- * @author David Bennett - Initial contribution
- */
-public class NestUpdateRequest {
- private final String updatePath;
- private final Map<String, Object> values;
-
- private NestUpdateRequest(Builder builder) {
- this.updatePath = builder.basePath + builder.identifier;
- this.values = builder.values;
- }
-
- public String getUpdatePath() {
- return updatePath;
- }
-
- public Map<String, Object> getValues() {
- return values;
- }
-
- public static class Builder {
- private String basePath;
- private String identifier;
- private Map<String, Object> values = new HashMap<>();
-
- public Builder withBasePath(String basePath) {
- this.basePath = basePath;
- return this;
- }
-
- public Builder withIdentifier(String identifier) {
- this.identifier = identifier;
- return this;
- }
-
- public Builder withAdditionalValue(String field, Object value) {
- values.put(field, value);
- return this;
- }
-
- public NestUpdateRequest build() {
- return new NestUpdateRequest(this);
- }
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm;
+
+import static java.util.Map.entry;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nest.internal.sdm.dto.SDMDeviceType;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link SDMBindingConstants} class defines common constants, which are used for the SDM implementation in the
+ * binding.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMBindingConstants {
+
+ private static final String BINDING_ID = "nest";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "sdm_account");
+ public static final ThingTypeUID THING_TYPE_CAMERA = new ThingTypeUID(BINDING_ID, "sdm_camera");
+ public static final ThingTypeUID THING_TYPE_DISPLAY = new ThingTypeUID(BINDING_ID, "sdm_display");
+ public static final ThingTypeUID THING_TYPE_DOORBELL = new ThingTypeUID(BINDING_ID, "sdm_doorbell");
+ public static final ThingTypeUID THING_TYPE_THERMOSTAT = new ThingTypeUID(BINDING_ID, "sdm_thermostat");
+
+ public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_CAMERA,
+ THING_TYPE_DISPLAY, THING_TYPE_DOORBELL, THING_TYPE_THERMOSTAT);
+
+ // Maps SDM device types to Thing Types UIDs
+ public static final Map<SDMDeviceType, ThingTypeUID> SDM_THING_TYPE_MAPPING = Map.ofEntries(
+ entry(SDMDeviceType.CAMERA, THING_TYPE_CAMERA), //
+ entry(SDMDeviceType.DISPLAY, THING_TYPE_DISPLAY), //
+ entry(SDMDeviceType.DOORBELL, THING_TYPE_DOORBELL), //
+ entry(SDMDeviceType.THERMOSTAT, THING_TYPE_THERMOSTAT));
+
+ // List of all Channel ids
+ public static final String CHANNEL_CHIME_EVENT_IMAGE = "chime_event#image";
+ public static final String CHANNEL_CHIME_EVENT_TIMESTAMP = "chime_event#timestamp";
+ public static final String CHANNEL_LIVE_STREAM_URL = "live_stream#url";
+ public static final String CHANNEL_LIVE_STREAM_CURRENT_TOKEN = "live_stream#current_token";
+ public static final String CHANNEL_LIVE_STREAM_EXPIRATION_TIMESTAMP = "live_stream#expiration_timestamp";
+ public static final String CHANNEL_LIVE_STREAM_EXTENSION_TOKEN = "live_stream#extension_token";
+ public static final String CHANNEL_MOTION_EVENT_IMAGE = "motion_event#image";
+ public static final String CHANNEL_MOTION_EVENT_TIMESTAMP = "motion_event#timestamp";
+ public static final String CHANNEL_PERSON_EVENT_IMAGE = "person_event#image";
+ public static final String CHANNEL_PERSON_EVENT_TIMESTAMP = "person_event#timestamp";
+ public static final String CHANNEL_SOUND_EVENT_IMAGE = "sound_event#image";
+ public static final String CHANNEL_SOUND_EVENT_TIMESTAMP = "sound_event#timestamp";
+
+ public static final String CHANNEL_AMBIENT_HUMIDITY = "ambient_humidity";
+ public static final String CHANNEL_AMBIENT_TEMPERATURE = "ambient_temperature";
+ public static final String CHANNEL_CURRENT_ECO_MODE = "current_eco_mode";
+ public static final String CHANNEL_CURRENT_MODE = "current_mode";
+ public static final String CHANNEL_FAN_TIMER_MODE = "fan_timer_mode";
+ public static final String CHANNEL_FAN_TIMER_TIMEOUT = "fan_timer_timeout";
+ public static final String CHANNEL_HVAC_STATUS = "hvac_status";
+ public static final String CHANNEL_MAXIMUM_TEMPERATURE = "maximum_temperature";
+ public static final String CHANNEL_MINIMUM_TEMPERATURE = "minimum_temperature";
+ public static final String CHANNEL_TARGET_TEMPERATURE = "target_temperature";
+
+ // List of all configuration property IDs
+ public static final String CONFIG_PROPERTY_FAN_TIMER_DURATION = "fanTimerDuration";
+ public static final String CONFIG_PROPERTY_IMAGE_HEIGHT = "imageHeight";
+ public static final String CONFIG_PROPERTY_IMAGE_WIDTH = "imageWidth";
+
+ // List of all property IDs
+ public static final String PROPERTY_AUDIO_CODECS = "audioCodecs";
+ public static final String PROPERTY_CUSTOM_NAME = "customName";
+ public static final String PROPERTY_MAX_IMAGE_RESOLUTION = "maxImageResolution";
+ public static final String PROPERTY_MAX_VIDEO_RESOLUTION = "maxVideoResolution";
+ public static final String PROPERTY_SUPPORTED_PROTOCOLS = "supportedProtocols";
+ public static final String PROPERTY_ROOM = "room";
+ public static final String PROPERTY_TEMPERATURE_SCALE = "temperatureScale";
+ public static final String PROPERTY_VIDEO_CODECS = "videoCodecs";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm;
+
+import static org.openhab.binding.nest.internal.sdm.SDMBindingConstants.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.sdm.handler.SDMAccountHandler;
+import org.openhab.binding.nest.internal.sdm.handler.SDMCameraHandler;
+import org.openhab.binding.nest.internal.sdm.handler.SDMThermostatHandler;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link SDMThingHandlerFactory} is responsible for creating SDM thing handlers.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.nest")
+@NonNullByDefault
+public class SDMThingHandlerFactory extends BaseThingHandlerFactory {
+
+ private HttpClientFactory httpClientFactory;
+ private OAuthFactory oAuthFactory;
+ private final TimeZoneProvider timeZoneProvider;
+
+ @Activate
+ public SDMThingHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
+ final @Reference OAuthFactory oAuthFactory, final @Reference TimeZoneProvider timeZoneProvider) {
+ this.httpClientFactory = httpClientFactory;
+ this.oAuthFactory = oAuthFactory;
+ this.timeZoneProvider = timeZoneProvider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (thingTypeUID.equals(THING_TYPE_ACCOUNT)) {
+ return new SDMAccountHandler((Bridge) thing, httpClientFactory, oAuthFactory);
+ } else if (thingTypeUID.equals(THING_TYPE_CAMERA)) {
+ return new SDMCameraHandler(thing, timeZoneProvider);
+ } else if (thingTypeUID.equals(THING_TYPE_DISPLAY)) {
+ return new SDMCameraHandler(thing, timeZoneProvider);
+ } else if (thingTypeUID.equals(THING_TYPE_DOORBELL)) {
+ return new SDMCameraHandler(thing, timeZoneProvider);
+ } else if (thingTypeUID.equals(THING_TYPE_THERMOSTAT)) {
+ return new SDMThermostatHandler(thing, timeZoneProvider);
+ }
+
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.api;
+
+import static org.eclipse.jetty.http.HttpHeader.*;
+import static org.eclipse.jetty.http.HttpMethod.POST;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMGson.GSON;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpMethod;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubAcknowledgeRequest;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubCreateRequest;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubPullRequest;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubPullResponse;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingPubSubDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidPubSubAccessTokenException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidPubSubAuthorizationCodeException;
+import org.openhab.binding.nest.internal.sdm.listener.PubSubSubscriptionListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
+import org.openhab.core.common.NamedThreadFactory;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link PubSubAPI} implements a subset of the Pub/Sub REST API which allows for subscribing to SDM events.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://cloud.google.com/pubsub/docs/reference/rest
+ * @see https://developers.google.com/nest/device-access/api/events
+ */
+@NonNullByDefault
+public class PubSubAPI {
+
+ private class Subscriber implements Runnable {
+
+ private final String subscriptionId;
+
+ Subscriber(String subscriptionId) {
+ this.subscriptionId = subscriptionId;
+ }
+
+ @Override
+ public void run() {
+ if (!subscriptionListeners.containsKey(subscriptionId)) {
+ logger.debug("Stop receiving subscription '{}' messages since there are no listeners", subscriptionId);
+ return;
+ }
+
+ try {
+ String messages = pullSubscriptionMessages(subscriptionId);
+
+ PubSubPullResponse pullResponse = GSON.fromJson(messages, PubSubPullResponse.class);
+
+ if (pullResponse != null && pullResponse.receivedMessages != null) {
+ logger.debug("Subscription '{}' has {} new message(s)", subscriptionId,
+ pullResponse.receivedMessages.size());
+ forEachListener((listener) -> pullResponse.receivedMessages
+ .forEach((message) -> listener.onMessage(message.message)));
+ List<String> ackIds = pullResponse.receivedMessages.stream().map(message -> message.ackId)
+ .collect(Collectors.toList());
+ acknowledgeSubscriptionMessages(subscriptionId, ackIds);
+ } else {
+ forEachListener((listener) -> listener.onNoNewMessages());
+ }
+
+ scheduler.submit(this);
+ } catch (FailedSendingPubSubDataException e) {
+ logger.debug("Expected exception while pulling message for '{}' subscription", subscriptionId, e);
+ Throwable cause = e.getCause();
+ if (!(cause instanceof InterruptedException)) {
+ forEachListener((listener) -> listener.onError(e));
+ scheduler.schedule(this, RETRY_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
+ }
+ } catch (InvalidPubSubAccessTokenException e) {
+ logger.warn("Cannot pull messages for '{}' subscription (access token invalid)", subscriptionId, e);
+ forEachListener((listener) -> listener.onError(e));
+ } catch (Exception e) {
+ logger.warn("Unexpected exception while pulling message for '{}' subscription", subscriptionId, e);
+ forEachListener((listener) -> listener.onError(e));
+ scheduler.schedule(this, RETRY_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
+ }
+ }
+
+ private void forEachListener(Consumer<PubSubSubscriptionListener> consumer) {
+ Set<PubSubSubscriptionListener> listeners = subscriptionListeners.get(subscriptionId);
+ if (listeners != null) {
+ listeners.forEach(consumer::accept);
+ } else {
+ logger.debug("Subscription '{}' has no listeners", subscriptionId);
+ }
+ }
+ }
+
+ private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/auth";
+ private static final String TOKEN_URL = "https://accounts.google.com/o/oauth2/token";
+ private static final String REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
+
+ private static final String PUBSUB_HANDLE_FORMAT = "%s.pubsub";
+ private static final String PUBSUB_SCOPE = "https://www.googleapis.com/auth/pubsub";
+
+ private static final String PUBSUB_URL_PREFIX = "https://pubsub.googleapis.com/v1/";
+ private static final int PUBSUB_PULL_MAX_MESSAGES = 10;
+
+ private static final String APPLICATION_JSON = "application/json";
+ private static final String BEARER = "Bearer ";
+
+ private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
+ private static final Duration RETRY_TIMEOUT = Duration.ofSeconds(30);
+
+ private final Logger logger = LoggerFactory.getLogger(PubSubAPI.class);
+
+ private final HttpClient httpClient;
+ private final OAuthClientService oAuthService;
+ private final String projectId;
+ private final ScheduledThreadPoolExecutor scheduler;
+ private final Map<String, Set<PubSubSubscriptionListener>> subscriptionListeners = new HashMap<>();
+
+ public PubSubAPI(HttpClientFactory httpClientFactory, OAuthFactory oAuthFactory, String ownerId, String projectId,
+ String clientId, String clientSecret) {
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.projectId = projectId;
+ this.oAuthService = oAuthFactory.createOAuthClientService(String.format(PUBSUB_HANDLE_FORMAT, ownerId),
+ TOKEN_URL, AUTH_URL, clientId, clientSecret, PUBSUB_SCOPE, false);
+ scheduler = new ScheduledThreadPoolExecutor(3, new NamedThreadFactory(ownerId, true));
+ }
+
+ public void dispose() {
+ subscriptionListeners.clear();
+ scheduler.shutdownNow();
+ }
+
+ public void authorizeClient(String authorizationCode) throws InvalidPubSubAuthorizationCodeException, IOException {
+ try {
+ oAuthService.getAccessTokenResponseByAuthorizationCode(authorizationCode, REDIRECT_URI);
+ } catch (OAuthException | OAuthResponseException e) {
+ throw new InvalidPubSubAuthorizationCodeException(
+ "Failed to authorize Pub/Sub client. Check the authorization code or generate a new one.", e);
+ }
+ }
+
+ public void checkAccessTokenValidity() throws InvalidPubSubAccessTokenException, IOException {
+ getAuthorizationHeader();
+ }
+
+ private String acknowledgeSubscriptionMessages(String subscriptionId, List<String> ackIds)
+ throws FailedSendingPubSubDataException, InvalidPubSubAccessTokenException {
+ logger.debug("Acknowleding {} message(s) for '{}' subscription", ackIds.size(), subscriptionId);
+ String url = getSubscriptionUrl(subscriptionId) + ":acknowledge";
+ String requestContent = GSON.toJson(new PubSubAcknowledgeRequest(ackIds));
+ return postJson(url, requestContent);
+ }
+
+ public void addSubscriptionListener(String subscriptionId, PubSubSubscriptionListener listener) {
+ synchronized (subscriptionListeners) {
+ Set<PubSubSubscriptionListener> listeners = subscriptionListeners.get(subscriptionId);
+ if (listeners == null) {
+ listeners = new HashSet<>();
+ subscriptionListeners.put(subscriptionId, listeners);
+ }
+ listeners.add(listener);
+ if (listeners.size() == 1) {
+ scheduler.submit(new Subscriber(subscriptionId));
+ }
+ }
+ }
+
+ public void removeSubscriptionListener(String subscriptionId, PubSubSubscriptionListener listener) {
+ synchronized (subscriptionListeners) {
+ Set<PubSubSubscriptionListener> listeners = subscriptionListeners.get(subscriptionId);
+ if (listeners != null) {
+ listeners.remove(listener);
+ if (listeners.isEmpty()) {
+ subscriptionListeners.remove(subscriptionId);
+ scheduler.getQueue().removeIf((runnable) -> runnable instanceof Subscriber
+ && ((Subscriber) runnable).subscriptionId.equals(subscriptionId));
+ }
+ }
+ }
+ }
+
+ public void createSubscription(String subscriptionId, String topicName)
+ throws FailedSendingPubSubDataException, InvalidPubSubAccessTokenException {
+ logger.debug("Creating '{}' subscription", subscriptionId);
+ String url = getSubscriptionUrl(subscriptionId);
+ String requestContent = GSON.toJson(new PubSubCreateRequest(topicName, true));
+ putJson(url, requestContent);
+ }
+
+ private String getAuthorizationHeader() throws InvalidPubSubAccessTokenException, IOException {
+ try {
+ AccessTokenResponse response = oAuthService.getAccessTokenResponse();
+ if (response == null || response.getAccessToken() == null || response.getAccessToken().isEmpty()) {
+ throw new InvalidPubSubAccessTokenException(
+ "No Pub/Sub access token. Client may not have been authorized.");
+ }
+ return BEARER + response.getAccessToken();
+ } catch (OAuthException | OAuthResponseException e) {
+ throw new InvalidPubSubAccessTokenException(
+ "Error fetching Pub/Sub access token. Check the authorization code or generate a new one.", e);
+ }
+ }
+
+ private String getSubscriptionUrl(String subscriptionId) {
+ return PUBSUB_URL_PREFIX + "projects/" + projectId + "/subscriptions/" + subscriptionId;
+ }
+
+ private String postJson(String url, String requestContent)
+ throws FailedSendingPubSubDataException, InvalidPubSubAccessTokenException {
+ try {
+ logger.debug("Posting JSON to: {}", url);
+ String response = httpClient.newRequest(url) //
+ .method(POST) //
+ .header(ACCEPT, APPLICATION_JSON) //
+ .header(AUTHORIZATION, getAuthorizationHeader()) //
+ .content(new StringContentProvider(requestContent), APPLICATION_JSON) //
+ .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
+ .send() //
+ .getContentAsString();
+ logger.debug("Response: {}", response);
+ return response;
+ } catch (ExecutionException | InterruptedException | IOException | TimeoutException e) {
+ throw new FailedSendingPubSubDataException("Failed to send JSON POST request", e);
+ }
+ }
+
+ private String pullSubscriptionMessages(String subscriptionId)
+ throws FailedSendingPubSubDataException, InvalidPubSubAccessTokenException {
+ logger.debug("Pulling messages for '{}' subscription", subscriptionId);
+ String url = getSubscriptionUrl(subscriptionId) + ":pull";
+ String requestContent = GSON.toJson(new PubSubPullRequest(PUBSUB_PULL_MAX_MESSAGES));
+ return postJson(url, requestContent);
+ }
+
+ private String putJson(String url, String requestContent)
+ throws FailedSendingPubSubDataException, InvalidPubSubAccessTokenException {
+ try {
+ logger.debug("Putting JSON to: {}", url);
+ String response = httpClient.newRequest(url) //
+ .method(HttpMethod.PUT) //
+ .header(ACCEPT, APPLICATION_JSON) //
+ .header(AUTHORIZATION, getAuthorizationHeader()) //
+ .content(new StringContentProvider(requestContent), APPLICATION_JSON) //
+ .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
+ .send() //
+ .getContentAsString();
+ logger.debug("Response: {}", response);
+ return response;
+ } catch (ExecutionException | InterruptedException | IOException | TimeoutException e) {
+ throw new FailedSendingPubSubDataException("Failed to send JSON PUT request", e);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.api;
+
+import static org.eclipse.jetty.http.HttpHeader.*;
+import static org.eclipse.jetty.http.HttpMethod.*;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMGson.GSON;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCommandRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCommandResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMDevice;
+import org.openhab.binding.nest.internal.sdm.dto.SDMError;
+import org.openhab.binding.nest.internal.sdm.dto.SDMError.SDMErrorDetails;
+import org.openhab.binding.nest.internal.sdm.dto.SDMListDevicesResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMListRoomsResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMListStructuresResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMRoom;
+import org.openhab.binding.nest.internal.sdm.dto.SDMStructure;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAuthorizationCodeException;
+import org.openhab.binding.nest.internal.sdm.listener.SDMAPIRequestListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SDMAPI} implements the SDM REST API which allows for querying Nest device, structure and room information
+ * as well as executing device commands.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/rest
+ */
+@NonNullByDefault
+public class SDMAPI {
+
+ private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/auth";
+ private static final String TOKEN_URL = "https://accounts.google.com/o/oauth2/token";
+ private static final String REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
+
+ private static final String SDM_HANDLE_FORMAT = "%s.sdm";
+ private static final String SDM_SCOPE = "https://www.googleapis.com/auth/sdm.service";
+
+ private static final String SDM_URL_PREFIX = "https://smartdevicemanagement.googleapis.com/v1/enterprises/";
+
+ private static final String APPLICATION_JSON = "application/json";
+ private static final String BEARER = "Bearer ";
+ private static final String IMAGE_JPEG = "image/jpeg";
+
+ private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
+
+ private final Logger logger = LoggerFactory.getLogger(SDMAPI.class);
+
+ private final HttpClient httpClient;
+ private final OAuthClientService oAuthService;
+ private final String projectId;
+
+ private final Set<SDMAPIRequestListener> requestListeners = ConcurrentHashMap.newKeySet();
+
+ public SDMAPI(HttpClientFactory httpClientFactory, OAuthFactory oAuthFactory, String ownerId, String projectId,
+ String clientId, String clientSecret) {
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.oAuthService = oAuthFactory.createOAuthClientService(String.format(SDM_HANDLE_FORMAT, ownerId), TOKEN_URL,
+ AUTH_URL, clientId, clientSecret, SDM_SCOPE, false);
+ this.projectId = projectId;
+ }
+
+ public void dispose() {
+ requestListeners.clear();
+ }
+
+ public void authorizeClient(String authorizationCode) throws InvalidSDMAuthorizationCodeException, IOException {
+ try {
+ oAuthService.getAccessTokenResponseByAuthorizationCode(authorizationCode, REDIRECT_URI);
+ } catch (OAuthException | OAuthResponseException e) {
+ throw new InvalidSDMAuthorizationCodeException(
+ "Failed to authorize SDM client. Check the authorization code or generate a new one.", e);
+ }
+ }
+
+ public void checkAccessTokenValidity() throws InvalidSDMAccessTokenException, IOException {
+ getAuthorizationHeader();
+ }
+
+ public void addRequestListener(SDMAPIRequestListener listener) {
+ requestListeners.add(listener);
+ }
+
+ public void removeRequestListener(SDMAPIRequestListener listener) {
+ requestListeners.remove(listener);
+ }
+
+ public <T extends SDMCommandResponse> @Nullable T executeDeviceCommand(String deviceId,
+ SDMCommandRequest<T> request) throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Executing device command for: {}", deviceId);
+ String requestContent = GSON.toJson(request);
+ String responseContent = postJson(getDeviceUrl(deviceId) + ":executeCommand", requestContent);
+ return GSON.fromJson(responseContent, request.getResponseClass());
+ }
+
+ private String getAuthorizationHeader() throws InvalidSDMAccessTokenException, IOException {
+ try {
+ AccessTokenResponse response = oAuthService.getAccessTokenResponse();
+ if (response == null || response.getAccessToken() == null || response.getAccessToken().isEmpty()) {
+ throw new InvalidSDMAccessTokenException("No SDM access token. Client may not have been authorized.");
+ }
+ return BEARER + response.getAccessToken();
+ } catch (OAuthException | OAuthResponseException e) {
+ throw new InvalidSDMAccessTokenException(
+ "Error fetching SDM access token. Check the authorization code or generate a new one.", e);
+ }
+ }
+
+ public byte[] getCameraImage(String url, String token, @Nullable BigDecimal imageWidth,
+ @Nullable BigDecimal imageHeight) throws FailedSendingSDMDataException {
+ try {
+ logger.debug("Getting camera image from: {}", url);
+
+ Request request = httpClient.newRequest(url) //
+ .method(GET) //
+ .header(ACCEPT, IMAGE_JPEG) //
+ .header(AUTHORIZATION, token) //
+ .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS);
+
+ if (imageWidth != null) {
+ request = request.param("width", Long.toString(imageWidth.longValue()));
+ } else if (imageHeight != null) {
+ request = request.param("height", Long.toString(imageHeight.longValue()));
+ }
+
+ ContentResponse contentResponse = request.send();
+ logResponseErrors(contentResponse);
+ logger.debug("Retrieved camera image from: {}", url);
+ requestListeners.forEach(listener -> listener.onSuccess());
+ return contentResponse.getContent();
+ } catch (ExecutionException | InterruptedException | TimeoutException e) {
+ logger.debug("Failed to get camera image", e);
+ FailedSendingSDMDataException exception = new FailedSendingSDMDataException("Failed to get camera image",
+ e);
+ requestListeners.forEach(listener -> listener.onError(exception));
+ throw exception;
+ }
+ }
+
+ public @Nullable SDMDevice getDevice(String deviceId)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Getting device: {}", deviceId);
+ return GSON.fromJson(getJson(getDeviceUrl(deviceId)), SDMDevice.class);
+ }
+
+ public @Nullable SDMStructure getStructure(String structureId)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Getting structure: {}", structureId);
+ return GSON.fromJson(getJson(getStructureUrl(structureId)), SDMStructure.class);
+ }
+
+ public @Nullable SDMRoom getRoom(String structureId, String roomId)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Getting structure {} room: {}", structureId, roomId);
+ return GSON.fromJson(getJson(getRoomUrl(structureId, roomId)), SDMRoom.class);
+ }
+
+ private String getProjectUrl() {
+ return SDM_URL_PREFIX + projectId;
+ }
+
+ private String getDevicesUrl() {
+ return getProjectUrl() + "/devices";
+ }
+
+ private String getDevicesUrl(String pageToken) {
+ return getDevicesUrl() + "?pageToken=" + pageToken;
+ }
+
+ private String getDeviceUrl(String deviceId) {
+ return getDevicesUrl() + "/" + deviceId;
+ }
+
+ private String getStructuresUrl() {
+ return getProjectUrl() + "/structures";
+ }
+
+ private String getStructuresUrl(String pageToken) {
+ return getStructuresUrl() + "?pageToken=" + pageToken;
+ }
+
+ private String getStructureUrl(String structureId) {
+ return getStructuresUrl() + "/" + structureId;
+ }
+
+ private String getRoomsUrl(String structureId) {
+ return getStructureUrl(structureId) + "/rooms";
+ }
+
+ private String getRoomsUrl(String structureId, String pageToken) {
+ return getRoomsUrl(structureId) + "?pageToken=" + pageToken;
+ }
+
+ private String getRoomUrl(String structureId, String roomId) {
+ return getRoomsUrl(structureId) + "/" + roomId;
+ }
+
+ public List<SDMDevice> listDevices() throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Listing devices");
+ SDMListDevicesResponse response = GSON.fromJson(getJson(getDevicesUrl()), SDMListDevicesResponse.class);
+ List<SDMDevice> result = response == null ? List.of() : response.devices;
+ while (response != null && !response.nextPageToken.isEmpty()) {
+ response = GSON.fromJson(getJson(getDevicesUrl(response.nextPageToken)), SDMListDevicesResponse.class);
+ if (response != null) {
+ result.addAll(response.devices);
+ }
+ }
+ return result;
+ }
+
+ public List<SDMStructure> listStructures() throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Listing structures");
+ SDMListStructuresResponse response = GSON.fromJson(getJson(getStructuresUrl()),
+ SDMListStructuresResponse.class);
+ List<SDMStructure> result = response == null ? List.of() : response.structures;
+ while (response != null && !response.nextPageToken.isEmpty()) {
+ response = GSON.fromJson(getJson(getStructuresUrl(response.nextPageToken)),
+ SDMListStructuresResponse.class);
+ if (response != null) {
+ result.addAll(response.structures);
+ }
+ }
+ return result;
+ }
+
+ public List<SDMRoom> listRooms(String structureId)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("Listing rooms for structure: {}", structureId);
+ SDMListRoomsResponse response = GSON.fromJson(getJson(getRoomsUrl(structureId)), SDMListRoomsResponse.class);
+ List<SDMRoom> result = response == null ? List.of() : response.rooms;
+ while (response != null && !response.nextPageToken.isEmpty()) {
+ response = GSON.fromJson(getJson(getRoomsUrl(structureId, response.nextPageToken)),
+ SDMListRoomsResponse.class);
+ if (response != null) {
+ result.addAll(response.rooms);
+ }
+ }
+ return result;
+ }
+
+ private void logResponseErrors(ContentResponse contentResponse) {
+ if (contentResponse.getStatus() >= 400) {
+ logger.debug("SDM API error: {}", contentResponse.getContentAsString());
+
+ SDMError error = GSON.fromJson(contentResponse.getContentAsString(), SDMError.class);
+ SDMErrorDetails details = error == null ? null : error.error;
+
+ if (details != null && !details.message.isBlank()) {
+ logger.warn("SDM API error: {}", details.message);
+ } else {
+ logger.warn("SDM API error: {} (HTTP {})", contentResponse.getReason(), contentResponse.getStatus());
+ }
+ }
+ }
+
+ private String getJson(String url) throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ try {
+ logger.debug("Getting JSON from: {}", url);
+ ContentResponse contentResponse = httpClient.newRequest(url) //
+ .method(GET) //
+ .header(ACCEPT, APPLICATION_JSON) //
+ .header(AUTHORIZATION, getAuthorizationHeader()) //
+ .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
+ .send();
+ logResponseErrors(contentResponse);
+ String response = contentResponse.getContentAsString();
+ logger.debug("Response: {}", response);
+ requestListeners.forEach(listener -> listener.onSuccess());
+ return response;
+ } catch (ExecutionException | InterruptedException | IOException | TimeoutException e) {
+ logger.debug("Failed to send JSON GET request", e);
+ FailedSendingSDMDataException exception = new FailedSendingSDMDataException(
+ "Failed to send JSON GET request", e);
+ requestListeners.forEach(listener -> listener.onError(exception));
+ throw exception;
+ }
+ }
+
+ private String postJson(String url, String requestContent)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ try {
+ logger.debug("Posting JSON to: {}", url);
+ ContentResponse contentResponse = httpClient.newRequest(url) //
+ .method(POST) //
+ .header(ACCEPT, APPLICATION_JSON) //
+ .header(AUTHORIZATION, getAuthorizationHeader()) //
+ .content(new StringContentProvider(requestContent), APPLICATION_JSON) //
+ .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
+ .send();
+ logResponseErrors(contentResponse);
+ String response = contentResponse.getContentAsString();
+ logger.debug("Response: {}", response);
+ requestListeners.forEach(listener -> listener.onSuccess());
+ return response;
+ } catch (ExecutionException | InterruptedException | IOException | TimeoutException e) {
+ logger.debug("Failed to send JSON POST request", e);
+ FailedSendingSDMDataException exception = new FailedSendingSDMDataException(
+ "Failed to send JSON POST request", e);
+ requestListeners.forEach(listener -> listener.onError(exception));
+ throw exception;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.config;
+
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SDMAccountConfiguration} contains the configuration parameter values for the SDM and Pub/Sub APIs.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMAccountConfiguration {
+
+ public static final String PUBSUB_AUTHORIZATION_CODE = "pubsubAuthorizationCode";
+ public String pubsubAuthorizationCode = "";
+
+ public static final String PUBSUB_CLIENT_ID = "pubsubClientId";
+ public String pubsubClientId = "";
+
+ public static final String PUBSUB_CLIENT_SECRET = "pubsubClientSecret";
+ public String pubsubClientSecret = "";
+
+ public static final String PUBSUB_PROJECT_ID = "pubsubProjectId";
+ public String pubsubProjectId = "";
+
+ public static final String PUBSUB_SUBSCRIPTION_ID = "pubsubSubscriptionId";
+ public String pubsubSubscriptionId = "";
+
+ public static final String SDM_AUTHORIZATION_CODE = "sdmAuthorizationCode";
+ public String sdmAuthorizationCode = "";
+
+ public static final String SDM_CLIENT_ID = "sdmClientId";
+ public String sdmClientId = "";
+
+ public static final String SDM_CLIENT_SECRET = "sdmClientSecret";
+ public String sdmClientSecret = "";
+
+ public static final String SDM_PRODUCT_ID = "sdmProductId";
+ public String sdmProjectId = "";
+
+ public boolean usePubSub() {
+ return Stream.of(pubsubProjectId, pubsubSubscriptionId, pubsubClientId, pubsubClientSecret)
+ .noneMatch(String::isBlank);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SDMDeviceConfiguration} contains the configuration parameter values for a SDM device.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMDeviceConfiguration {
+
+ public static final String DEVICE_ID = "deviceId";
+ public String deviceId = "";
+
+ public static final String REFRESH_INTERVAL = "refreshInterval";
+ public int refreshInterval = 300;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.discovery;
+
+import static org.openhab.binding.nest.internal.sdm.SDMBindingConstants.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.Future;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.sdm.config.SDMDeviceConfiguration;
+import org.openhab.binding.nest.internal.sdm.dto.SDMDevice;
+import org.openhab.binding.nest.internal.sdm.dto.SDMDeviceType;
+import org.openhab.binding.nest.internal.sdm.dto.SDMParentRelation;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
+import org.openhab.binding.nest.internal.sdm.handler.SDMAccountHandler;
+import org.openhab.binding.nest.internal.sdm.handler.SDMBaseHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.osgi.service.component.ComponentContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SDMDiscoveryService} is discovers devices using the SDM API list devices method.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/rest/v1/enterprises.devices/list
+ */
+@NonNullByDefault
+public class SDMDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
+
+ private final Logger logger = LoggerFactory.getLogger(SDMDiscoveryService.class);
+ private @NonNullByDefault({}) SDMAccountHandler accountHandler;
+ private @Nullable Future<?> discoveryJob;
+
+ public SDMDiscoveryService() {
+ super(SUPPORTED_THING_TYPES_UIDS, 30, false);
+ }
+
+ protected void activate(ComponentContext context) {
+ }
+
+ @Override
+ public void deactivate() {
+ cancelDiscoveryJob();
+ super.deactivate();
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return accountHandler;
+ }
+
+ @Override
+ public void setThingHandler(ThingHandler handler) {
+ if (handler instanceof SDMAccountHandler) {
+ accountHandler = (SDMAccountHandler) handler;
+ }
+ }
+
+ @Override
+ protected void startScan() {
+ cancelDiscoveryJob();
+ discoveryJob = scheduler.submit(this::discoverDevices);
+ }
+
+ @Override
+ protected synchronized void stopScan() {
+ cancelDiscoveryJob();
+ super.stopScan();
+ }
+
+ private void cancelDiscoveryJob() {
+ Future<?> localDiscoveryJob = discoveryJob;
+ if (localDiscoveryJob != null) {
+ localDiscoveryJob.cancel(true);
+ }
+ }
+
+ private void discoverDevices() {
+ ThingUID bridgeUID = accountHandler.getThing().getUID();
+ logger.debug("Starting discovery scan for {}", bridgeUID);
+ try {
+ accountHandler.getAPI().listDevices().forEach(device -> addDeviceDiscoveryResult(bridgeUID, device));
+ } catch (FailedSendingSDMDataException | InvalidSDMAccessTokenException e) {
+ logger.debug("Exception during discovery scan for {}", bridgeUID, e);
+ }
+ logger.debug("Finished discovery scan for {}", bridgeUID);
+ }
+
+ private void addDeviceDiscoveryResult(ThingUID bridgeUID, SDMDevice device) {
+ SDMDeviceType type = device.type;
+ ThingTypeUID thingTypeUID = type == null ? null : SDM_THING_TYPE_MAPPING.get(type);
+ if (type == null || thingTypeUID == null) {
+ logger.debug("Ignoring unsupported device type: {}", type);
+ return;
+ }
+
+ String deviceId = device.name.deviceId;
+ ThingUID thingUID = new ThingUID(thingTypeUID, bridgeUID, deviceId);
+
+ thingDiscovered(DiscoveryResultBuilder.create(thingUID) //
+ .withThingType(thingTypeUID) //
+ .withLabel(getDeviceLabel(device, type)) //
+ .withBridge(bridgeUID) //
+ .withProperty(SDMDeviceConfiguration.DEVICE_ID, deviceId) //
+ .withProperties(new HashMap<>(SDMBaseHandler.getDeviceProperties(device))) //
+ .withRepresentationProperty(SDMDeviceConfiguration.DEVICE_ID) //
+ .build() //
+ );
+ }
+
+ private String getDeviceLabel(SDMDevice device, SDMDeviceType type) {
+ String label = device.traits.deviceInfo.customName;
+ if (!label.isBlank()) {
+ return label;
+ }
+
+ List<SDMParentRelation> parentRelations = device.parentRelations;
+ String displayName = !parentRelations.isEmpty() ? parentRelations.get(0).displayName : "";
+ String typeLabel = type.toLabel();
+
+ return displayName.isBlank() ? String.format("Nest %s", typeLabel)
+ : String.format("Nest %s %s", displayName, typeLabel);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+/**
+ * The {@link PubSubRequestsResponses} provides classes used for mapping Pub/Sub REST API requests and responses.
+ * Only the subset of requests/responses and fields that are used by the binding are implemented.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://cloud.google.com/pubsub/docs/reference/rest
+ */
+public class PubSubRequestsResponses {
+
+ // Method: projects.subscriptions.acknowledge
+
+ /**
+ * Acknowledges the messages associated with the ackIds in the AcknowledgeRequest. The Pub/Sub system can remove the
+ * relevant messages from the subscription.
+ *
+ * Acknowledging a message whose ack deadline has expired may succeed, but such a message may be redelivered later.
+ * Acknowledging a message more than once will not result in an error.
+ */
+ public static class PubSubAcknowledgeRequest {
+
+ public List<String> ackIds;
+
+ public PubSubAcknowledgeRequest(List<String> ackIds) {
+ this.ackIds = ackIds;
+ }
+ }
+
+ // Method: projects.subscriptions.create
+
+ /**
+ * Creates a subscription to a given topic. See the resource name rules. If the subscription already exists, returns
+ * ALREADY_EXISTS. If the corresponding topic doesn't exist, returns NOT_FOUND.
+ *
+ * If the name is not provided in the request, the server will assign a random name for this subscription on the
+ * same project as the topic, conforming to the resource name format. The generated name is populated in the
+ * returned Subscription object. Note that for REST API requests, you must specify a name in the request.
+ */
+ public static class PubSubCreateRequest {
+
+ public String topic;
+ public boolean enableMessageOrdering;
+
+ /**
+ * @param topic The name of the topic from which this subscription is receiving messages. Format is
+ * <code>projects/{project}/topics/{topic}</code>.
+ * @param enableMessageOrdering If true, messages published with the same orderingKey in the message will be
+ * delivered to the subscribers in the order in which they are received by the Pub/Sub system.
+ * Otherwise, they may be delivered in any order.
+ */
+ public PubSubCreateRequest(String topic, boolean enableMessageOrdering) {
+ this.topic = topic;
+ this.enableMessageOrdering = enableMessageOrdering;
+ }
+ }
+
+ // Method: projects.subscriptions.pull
+
+ /**
+ * Pulls messages from the server. The server may return UNAVAILABLE if there are too many concurrent pull requests
+ * pending for the given subscription.
+ *
+ * A {@link PubSubPullResponse} is returned when successful.
+ */
+ public static class PubSubPullRequest {
+
+ public int maxMessages;
+
+ /**
+ * @param maxMessages The maximum number of messages to return for this request. Must be a positive integer. The
+ * Pub/Sub system may return fewer than the number specified.
+ */
+ public PubSubPullRequest(int maxMessages) {
+ this.maxMessages = maxMessages;
+ }
+ }
+
+ /**
+ * A message that is published by publishers and consumed by subscribers.
+ */
+ public static class PubSubMessage {
+ /**
+ * The message data field. A base64-encoded string.
+ */
+ public String data;
+
+ /**
+ * ID of this message, assigned by the server when the message is published. Guaranteed to be unique within the
+ * topic. This value may be read by a subscriber that receives a PubsubMessage via a
+ * <code>subscriptions.pull</code> call or a push delivery. It must not be populated by the publisher in a
+ * topics.publish call.
+ */
+ public String messageId;
+
+ /**
+ * The time at which the message was published, populated by the server when it receives the topics.publish
+ * call. It must not be populated by the publisher in a topics publish call.
+ *
+ * A timestamp in RFC3339 UTC "Zulu" format, with nanosecond resolution and up to nine fractional digits.
+ * Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045123456Z".
+ */
+ public ZonedDateTime publishTime;
+ }
+
+ /**
+ * A message and its corresponding acknowledgment ID.
+ */
+ public static class PubSubReceivedMessage {
+ /**
+ * This ID can be used to acknowledge the received message.
+ */
+ public String ackId;
+
+ /**
+ * The message.
+ */
+ public PubSubMessage message;
+ }
+
+ /**
+ * Response to a {@link PubSubPullRequest}.
+ */
+ public class PubSubPullResponse {
+ /**
+ * Received Pub/Sub messages. The list will be empty if there are no more messages available in the backlog. For
+ * JSON, the response can be entirely empty. The Pub/Sub system may return fewer than the maxMessages requested
+ * even if there are more messages available in the backlog.
+ */
+ public List<PubSubReceivedMessage> receivedMessages;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static java.util.Map.entry;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMFanTimerMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatEcoMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatMode;
+
+/**
+ * The {@link SDMCommands} provides classes used for mapping all SDM REST API device command requests and responses.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/rest/v1/enterprises.devices/executeCommand
+ */
+public class SDMCommands {
+
+ /**
+ * Command request parent.
+ */
+ public abstract static class SDMCommandRequest<T extends SDMCommandResponse> {
+ private final String command;
+ private final Map<String, Object> params = new LinkedHashMap<>();
+
+ @SafeVarargs
+ private SDMCommandRequest(String command, Entry<String, Object>... params) {
+ this.command = command;
+ for (Entry<String, Object> param : params) {
+ this.params.put(param.getKey(), param.getValue());
+ }
+ }
+
+ public String getCommand() {
+ return command;
+ }
+
+ public Map<String, Object> getParams() {
+ return params;
+ }
+
+ @SuppressWarnings("unchecked")
+ public Class<T> getResponseClass() {
+ return (Class<T>) SDMCommandResponse.class;
+ }
+ }
+
+ /**
+ * Command response parent. This class is also used for responses without additional data.
+ */
+ public static class SDMCommandResponse {
+ }
+
+ // CameraEventImage trait commands
+
+ /**
+ * Generates a download URL for the image related to a camera event.
+ */
+ public static class SDMGenerateCameraImageRequest extends SDMCommandRequest<SDMGenerateCameraImageResponse> {
+
+ /**
+ * Event images expire 30 seconds after the event is published. Make sure to download the image prior to
+ * expiration.
+ */
+ public static final Duration EVENT_IMAGE_VALIDITY = Duration.ofSeconds(30);
+
+ /**
+ * @param eventId ID of the camera event to request a related image for.
+ */
+ public SDMGenerateCameraImageRequest(String eventId) {
+ super("sdm.devices.commands.CameraEventImage.GenerateImage", entry("eventId", eventId));
+ }
+
+ @Override
+ public Class<SDMGenerateCameraImageResponse> getResponseClass() {
+ return SDMGenerateCameraImageResponse.class;
+ }
+ }
+
+ public static class SDMGenerateCameraImageResults {
+ /**
+ * The URL to download the camera image from.
+ */
+ public String url;
+
+ /**
+ * Token to use in the HTTP Authorization header when downloading the camera image.
+ */
+ public String token;
+ }
+
+ public static class SDMGenerateCameraImageResponse extends SDMCommandResponse {
+ public SDMGenerateCameraImageResults results;
+ }
+
+ // CameraLiveStream trait commands
+
+ /**
+ * Request a token to access a camera RTSP live stream URL.
+ */
+ public static class SDMGenerateCameraRtspStreamRequest
+ extends SDMCommandRequest<SDMGenerateCameraRtspStreamResponse> {
+ public SDMGenerateCameraRtspStreamRequest() {
+ super("sdm.devices.commands.CameraLiveStream.GenerateRtspStream");
+ }
+
+ @Override
+ public Class<SDMGenerateCameraRtspStreamResponse> getResponseClass() {
+ return SDMGenerateCameraRtspStreamResponse.class;
+ }
+ }
+
+ /**
+ * Camera RTSP live stream URLs.
+ */
+ public static class SDMCameraRtspStreamUrls {
+ public String rtspUrl;
+ }
+
+ public static class SDMGenerateCameraRtspStreamResults {
+ /**
+ * Camera RTSP live stream URLs.
+ */
+ public SDMCameraRtspStreamUrls streamUrls;
+
+ /**
+ * Token to use to extend the {@link #streamToken} for an RTSP live stream.
+ */
+ public String streamExtensionToken;
+
+ /**
+ * Token to use to access an RTSP live stream.
+ */
+ public String streamToken;
+
+ /**
+ * Time at which both {@link #streamExtensionToken} and {@link #streamToken} expire.
+ */
+ public ZonedDateTime expiresAt;
+ }
+
+ public static class SDMGenerateCameraRtspStreamResponse extends SDMCommandResponse {
+ public SDMGenerateCameraRtspStreamResults results;
+ }
+
+ /**
+ * Request a new RTSP live stream URL access token to replace a valid RTSP access token before it expires. This is
+ * also used to replace a valid RTSP token from a previous ExtendRtspStream command request.
+ */
+ public static class SDMExtendCameraRtspStreamRequest extends SDMCommandRequest<SDMExtendCameraRtspStreamResponse> {
+ /**
+ * @param streamExtensionToken Token to use to request an extension to the RTSP streaming token.
+ */
+ public SDMExtendCameraRtspStreamRequest(String streamExtensionToken) {
+ super("sdm.devices.commands.CameraLiveStream.ExtendRtspStream",
+ entry("streamExtensionToken", streamExtensionToken));
+ }
+
+ @Override
+ public Class<SDMExtendCameraRtspStreamResponse> getResponseClass() {
+ return SDMExtendCameraRtspStreamResponse.class;
+ }
+ }
+
+ public static class SDMExtendCameraRtspStreamResults {
+ /**
+ * Token to use to view an existing RTSP live stream and to request an extension to the streaming token.
+ */
+ public String streamExtensionToken;
+
+ /**
+ * New token to use to access an existing RTSP live stream.
+ */
+ public String streamToken;
+
+ /**
+ * Time at which both {@link #streamExtensionToken} and {@link #streamToken} expire.
+ */
+ public ZonedDateTime expiresAt;
+ }
+
+ public static class SDMExtendCameraRtspStreamResponse extends SDMCommandResponse {
+ public SDMExtendCameraRtspStreamResults results;
+ }
+
+ /**
+ * Invalidates a valid RTSP access token and stops the RTSP live stream tied to that access token.
+ */
+ public static class SDMStopCameraRtspStreamRequest extends SDMCommandRequest<SDMCommandResponse> {
+ /**
+ * @param streamExtensionToken Token to use to invalidate an existing RTSP live stream.
+ */
+ public SDMStopCameraRtspStreamRequest(String streamExtensionToken) {
+ super("sdm.devices.commands.CameraLiveStream.StopRtspStream",
+ entry("streamExtensionToken", streamExtensionToken));
+ }
+ }
+
+ // Fan trait commands
+
+ /**
+ * Change the fan timer.
+ */
+ public static class SDMSetFanTimerRequest extends SDMCommandRequest<SDMCommandResponse> {
+ public SDMSetFanTimerRequest(SDMFanTimerMode timerMode) {
+ super("sdm.devices.commands.Fan.SetTimer", entry("timerMode", timerMode.name()));
+ }
+
+ /**
+ * @param duration Specifies the length of time in seconds that the timer is set to run.
+ * Range: "1s" to "43200s"
+ * Default: "900s"
+ */
+ public SDMSetFanTimerRequest(SDMFanTimerMode timerMode, Duration duration) {
+ super("sdm.devices.commands.Fan.SetTimer", entry("timerMode", timerMode.name()),
+ entry("duration", String.valueOf(duration.toSeconds()) + "s"));
+ }
+ }
+
+ // ThermostatEco trait commands
+
+ /**
+ * Change the thermostat Eco mode.
+ *
+ * To change the thermostat mode to HEAT, COOL, or HEATCOOL, use the {@link SDMSetThermostatModeRequest}.
+ * <br>
+ * <br>
+ * This command impacts other traits, based on the current status of, or changes to, the Eco mode:
+ * <ul>
+ * <li>If Eco mode is OFF, the thermostat mode will default to the last standard mode (HEAT, COOL, HEATCOOL, or OFF)
+ * that was active.</li>
+ * <li>If Eco mode is MANUAL_ECO:
+ * <ul>
+ * <li>Commands for the ThermostatTemperatureSetpoint trait are rejected.</li>
+ * <li>Temperature setpoints are not returned by the ThermostatTemperatureSetpoint trait.</li>
+ * </ul>
+ * </li>
+ * </ul>
+ *
+ * Some thermostat models do not support changing the Eco mode when the thermostat mode is OFF, according to the
+ * ThermostatMode trait. The thermostat mode must be changed to HEAT, COOL, or HEATCOOL prior to changing the Eco
+ * mode.
+ */
+ public static class SDMSetThermostatEcoModeRequest extends SDMCommandRequest<SDMCommandResponse> {
+ public SDMSetThermostatEcoModeRequest(SDMThermostatEcoMode mode) {
+ super("sdm.devices.commands.ThermostatEco.SetMode", entry("mode", mode.name()));
+ }
+ }
+
+ // ThermostatMode trait commands
+
+ /**
+ * Change the thermostat mode.
+ */
+ public static class SDMSetThermostatModeRequest extends SDMCommandRequest<SDMCommandResponse> {
+ public SDMSetThermostatModeRequest(SDMThermostatMode mode) {
+ super("sdm.devices.commands.ThermostatMode.SetMode", entry("mode", mode.name()));
+ }
+ }
+
+ // ThermostatTemperatureSetpoint trait commands
+
+ /**
+ * Sets the target temperature when the thermostat is in COOL mode.
+ */
+ public static class SDMSetThermostatCoolSetpointRequest extends SDMCommandRequest<SDMCommandResponse> {
+ /**
+ * @param temperature the target temperature in degrees Celsius
+ */
+ public SDMSetThermostatCoolSetpointRequest(BigDecimal temperature) {
+ super("sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool", entry("coolCelsius", temperature));
+ }
+ }
+
+ /**
+ * Sets the target temperature when the thermostat is in HEAT mode.
+ */
+ public static class SDMSetThermostatHeatSetpointRequest extends SDMCommandRequest<SDMCommandResponse> {
+ /**
+ * @param temperature the target temperature in degrees Celsius
+ */
+ public SDMSetThermostatHeatSetpointRequest(BigDecimal temperature) {
+ super("sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat", entry("heatCelsius", temperature));
+ }
+ }
+
+ /**
+ * Sets the minimum and maximum temperatures when the thermostat is in HEATCOOL mode.
+ */
+ public static class SDMSetThermostatRangeSetpointRequest extends SDMCommandRequest<SDMCommandResponse> {
+ /**
+ * @param minTemperature the minimum target temperature in degrees Celsius
+ * @param maxTemperature the maximum target temperature in degrees Celsius
+ */
+ public SDMSetThermostatRangeSetpointRequest(BigDecimal minTemperature, BigDecimal maxTemperature) {
+ super("sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange", entry("heatCelsius", minTemperature),
+ entry("coolCelsius", maxTemperature));
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * An instance of enterprise managed device in the property.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMDevice {
+ /**
+ * The resource name of the device.
+ */
+ public SDMResourceName name = SDMResourceName.NAMELESS;
+
+ /**
+ * Type of the device for general display purposes.
+ */
+ public @Nullable SDMDeviceType type;
+
+ /**
+ * Device traits.
+ */
+ public SDMTraits traits = new SDMTraits();
+
+ /**
+ * Assignee details of the device.
+ */
+ public List<SDMParentRelation> parentRelations = List.of();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Type of the SDM device.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public enum SDMDeviceType {
+ @SerializedName("sdm.devices.types.CAMERA")
+ CAMERA,
+
+ @SerializedName("sdm.devices.types.DISPLAY")
+ DISPLAY,
+
+ @SerializedName("sdm.devices.types.DOORBELL")
+ DOORBELL,
+
+ @SerializedName("sdm.devices.types.THERMOSTAT")
+ THERMOSTAT;
+
+ public String toLabel() {
+ return name().charAt(0) + name().toLowerCase().substring(1);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+/**
+ * An error response of the SDM API.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/errors/api
+ */
+public class SDMError {
+
+ public static class SDMErrorDetails {
+ public int code;
+ public String message;
+ public String status;
+ }
+
+ public SDMErrorDetails error;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link SDMEvent} is used for mapping the SDM event data received from the SDM API in messages pulled from a
+ * Pub/Sub topic.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/api/events
+ */
+public class SDMEvent {
+
+ /**
+ * An object that details information about the relation update.
+ */
+ public static class SDMRelationUpdate {
+ public SDMRelationUpdateType type;
+
+ /**
+ * The resource that the object now has a relation with.
+ */
+ public SDMResourceName subject;
+
+ /**
+ * The resource that triggered the event.
+ */
+ public SDMResourceName object;
+ }
+
+ public enum SDMRelationUpdateType {
+ CREATED,
+ DELETED,
+ UPDATED
+ }
+
+ /**
+ * An object that details information about the resource update.
+ */
+ public static class SDMResourceUpdate {
+ public SDMResourceName name;
+ public SDMTraits traits;
+ public SDMResourceUpdateEvents events;
+ }
+
+ public static class SDMDeviceEvent {
+ public String eventId;
+ public String eventSessionId;
+ }
+
+ public static class SDMResourceUpdateEvents extends SDMTraits {
+ @SerializedName("sdm.devices.events.CameraMotion.Motion")
+ public SDMDeviceEvent cameraMotionEvent;
+
+ @SerializedName("sdm.devices.events.CameraPerson.Person")
+ public SDMDeviceEvent cameraPersonEvent;
+
+ @SerializedName("sdm.devices.events.CameraSound.Sound")
+ public SDMDeviceEvent cameraSoundEvent;
+
+ @SerializedName("sdm.devices.events.DoorbellChime.Chime")
+ public SDMDeviceEvent doorbellChimeEvent;
+
+ public <T> Stream<SDMDeviceEvent> eventStream() {
+ return Stream.of(cameraMotionEvent, cameraPersonEvent, cameraSoundEvent, doorbellChimeEvent)
+ .filter(Objects::nonNull);
+ }
+
+ public List<SDMDeviceEvent> eventList() {
+ return eventStream().collect(Collectors.toList());
+ }
+
+ public Set<SDMDeviceEvent> eventSet() {
+ return eventStream().collect(Collectors.toSet());
+ }
+ }
+
+ /**
+ * The unique identifier for the event.
+ */
+ public String eventId;
+
+ /**
+ * An object that details information about the relation update.
+ */
+ public SDMRelationUpdate relationUpdate;
+
+ /**
+ * An object that indicates resources that might have similar updates to this event.
+ * The resource of the event itself (from the resourceUpdate object) will always be present in this object.
+ */
+ public List<SDMResourceName> resourceGroup;
+
+ /**
+ * An object that details information about the resource update.
+ */
+ public SDMResourceUpdate resourceUpdate;
+
+ /**
+ * The time when the event occurred.
+ */
+ public ZonedDateTime timestamp;
+
+ /**
+ * A unique, obfuscated identifier that represents the user.
+ */
+ public String userId;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.lang.reflect.Type;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+
+/**
+ * The {@link SDMGson} class provides a {@link Gson} instance configured for (de)serializing all SDM and Pub/Sub data
+ * from/to JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMGson {
+
+ public static final Gson GSON = new GsonBuilder()
+ .registerTypeAdapter(SDMResourceName.class, new SDMResourceNameConverter()) //
+ .registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeConverter()) //
+ .create();
+
+ private static class SDMResourceNameConverter
+ implements JsonSerializer<SDMResourceName>, JsonDeserializer<SDMResourceName> {
+
+ @Override
+ public JsonElement serialize(SDMResourceName src, Type typeOfSrc, JsonSerializationContext context) {
+ return new JsonPrimitive(src.toString());
+ }
+
+ @Override
+ public @Nullable SDMResourceName deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ return new SDMResourceName(json.getAsString());
+ }
+ }
+
+ private static class ZonedDateTimeConverter
+ implements JsonSerializer<ZonedDateTime>, JsonDeserializer<ZonedDateTime> {
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
+
+ @Override
+ public JsonElement serialize(ZonedDateTime src, Type typeOfSrc, JsonSerializationContext context) {
+ return new JsonPrimitive(FORMATTER.format(src));
+ }
+
+ @Override
+ public @Nullable ZonedDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+ throws JsonParseException {
+ return ZonedDateTime.parse(json.getAsString());
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Interface for uniquely identifiable SDM objects (device, structure).
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public interface SDMIdentifiable {
+
+ /**
+ * Returns the identifier that uniquely identifies the SDM object (deviceId or structureId).
+ */
+ String getId();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Lists devices managed by the enterprise.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/rest/v1/enterprises.devices/list
+ */
+@NonNullByDefault
+public class SDMListDevicesResponse {
+ /**
+ * The list of devices.
+ */
+ public List<SDMDevice> devices = List.of();
+
+ /**
+ * The pagination token to retrieve the next page of results.
+ */
+ public String nextPageToken = "";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Lists rooms managed by the enterprise.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/rest/v1/enterprises.structures.rooms/list
+ */
+@NonNullByDefault
+public class SDMListRoomsResponse {
+ /**
+ * The list of rooms.
+ */
+ public List<SDMRoom> rooms = List.of();
+
+ /**
+ * The pagination token to retrieve the next page of results.
+ */
+ public String nextPageToken = "";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Lists structures managed by the enterprise.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @see https://developers.google.com/nest/device-access/reference/rest/v1/enterprises.structures/list
+ */
+@NonNullByDefault
+public class SDMListStructuresResponse {
+ /**
+ * The list of structures.
+ */
+ public List<SDMStructure> structures = List.of();
+
+ /**
+ * The pagination token to retrieve the next page of results.
+ */
+ public String nextPageToken = "";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+/**
+ * Represents device relationships, for instance, structure/room to which the device is assigned to.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class SDMParentRelation {
+ /**
+ * The name of the relation.
+ */
+ public SDMResourceName parent;
+
+ /**
+ * The custom name of the relation.
+ */
+ public String displayName;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * A resource name uniquely identifies a structure, room or device.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMResourceName {
+
+ public enum SDMResourceNameType {
+ DEVICE,
+ ROOM,
+ STRUCTURE,
+ UNKNOWN
+ }
+
+ private static final Pattern PATTERN = Pattern
+ .compile("^enterprises/([^/]+)(/devices/([^/]+)|/structures/([^/]+)(/rooms/([^/]+))?)$");
+
+ public static final SDMResourceName NAMELESS = new SDMResourceName("");
+
+ public final String name;
+ public final String projectId;
+ public final String deviceId;
+ public final String structureId;
+ public final String roomId;
+ public final SDMResourceNameType type;
+
+ public SDMResourceName(String name) {
+ this.name = name;
+
+ Matcher matcher = PATTERN.matcher(name);
+ if (matcher.matches()) {
+ projectId = matcher.group(1);
+ deviceId = matcher.group(3) == null ? "" : matcher.group(3);
+ structureId = matcher.group(4) == null ? "" : matcher.group(4);
+ roomId = matcher.group(6) == null ? "" : matcher.group(6);
+
+ if (!deviceId.isEmpty()) {
+ type = SDMResourceNameType.DEVICE;
+ } else if (!roomId.isEmpty()) {
+ type = SDMResourceNameType.ROOM;
+ } else if (!structureId.isEmpty()) {
+ type = SDMResourceNameType.STRUCTURE;
+ } else {
+ type = SDMResourceNameType.UNKNOWN;
+ }
+ } else {
+ projectId = "";
+ deviceId = "";
+ structureId = "";
+ roomId = "";
+ type = SDMResourceNameType.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ return prime * result + name.hashCode();
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ return name.equals(((SDMResourceName) obj).name);
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+/**
+ * An instance of enterprise managed room in a structure.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class SDMRoom {
+ /**
+ * The resource name of the room.
+ */
+ public SDMResourceName name = SDMResourceName.NAMELESS;
+
+ /**
+ * Room traits.
+ */
+ public SDMTraits traits = new SDMTraits();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+/**
+ * An instance of an enterprise managed structure.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class SDMStructure {
+ /**
+ * The resource name of the structure.
+ */
+ public SDMResourceName name = SDMResourceName.NAMELESS;
+
+ /**
+ * Structure traits.
+ */
+ public SDMTraits traits = new SDMTraits();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import java.math.BigDecimal;
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The common SDM traits that are used in the {@link SDMDevice} and {@link SDMEvent} types.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class SDMTraits {
+
+ /**
+ * This trait belongs to any device that supports generation of images from events.
+ */
+ public static class SDMCameraEventImageTrait extends SDMCameraTrait {
+ }
+
+ /**
+ * This trait belongs to any device that supports taking images.
+ */
+ public static class SDMCameraImageTrait extends SDMCameraTrait {
+ /**
+ * Maximum image resolution that is supported.
+ */
+ public SDMResolution maxImageResolution;
+ }
+
+ /**
+ * This trait belongs to any device that supports live streaming.
+ */
+ public static class SDMCameraLiveStreamTrait extends SDMCameraTrait {
+ /**
+ * Maximum resolution of the video live stream.
+ */
+ public SDMResolution maxVideoResolution;
+
+ /**
+ * Video codecs supported for the live stream.
+ */
+ public List<String> videoCodecs;
+
+ /**
+ * Audio codecs supported for the live stream.
+ */
+ public List<String> audioCodecs;
+
+ /**
+ * Protocols supported for the live stream.
+ */
+ public List<String> supportedProtocols;
+ }
+
+ /**
+ * This trait belongs to any device that supports motion detection events.
+ */
+ public static class SDMCameraMotionTrait extends SDMCameraTrait {
+ }
+
+ /**
+ * This trait belongs to any device that supports person detection events.
+ */
+ public static class SDMCameraPersonTrait extends SDMCameraTrait {
+ }
+
+ /**
+ * This trait belongs to any device that supports sound detection events.
+ */
+ public static class SDMCameraSoundTrait extends SDMCameraTrait {
+ }
+
+ public static class SDMCameraTrait extends SDMTrait {
+ }
+
+ public enum SDMConnectivityStatus {
+ OFFLINE,
+ ONLINE
+ }
+
+ /**
+ * This trait belongs to any device that has connectivity information.
+ */
+ public static class SDMConnectivityTrait extends SDMDeviceTrait {
+ /**
+ * Device connectivity status.
+ */
+ public SDMConnectivityStatus status;
+ }
+
+ /**
+ * This trait belongs to any device for device-related information.
+ */
+ public static class SDMDeviceInfoTrait extends SDMDeviceTrait {
+ /**
+ * Custom name of the device. Corresponds to the Label value for a device in the Nest App.
+ */
+ public String customName;
+ }
+
+ /**
+ * This trait belongs to any device for device-related settings information.
+ */
+ public static class SDMDeviceSettingsTrait extends SDMDeviceTrait {
+ /**
+ * Format of the degrees displayed on a Google Nest Thermostat.
+ */
+ public SDMTemperatureScale temperatureScale;
+ }
+
+ public static class SDMDeviceTrait extends SDMTrait {
+ }
+
+ /**
+ * This trait belongs to any device that supports a doorbell chime and related press events.
+ */
+ public static class SDMDoorbellChimeTrait extends SDMDoorbellTrait {
+ }
+
+ public static class SDMDoorbellTrait extends SDMTrait {
+ }
+
+ public enum SDMThermostatEcoMode {
+ MANUAL_ECO,
+ OFF
+ }
+
+ /**
+ * This trait belongs to any device that has the system ability to control the fan.
+ */
+ public static class SDMFanTrait extends SDMDeviceTrait {
+ /**
+ * Current timer mode.
+ */
+ public SDMFanTimerMode timerMode;
+
+ /**
+ * Timestamp, in RFC 3339 format, at which timer mode will turn to OFF.
+ */
+ public ZonedDateTime timerTimeout;
+ }
+
+ /**
+ * This trait belongs to any device that has a sensor to measure humidity.
+ */
+ public static class SDMHumidityTrait extends SDMDeviceTrait {
+ /**
+ * Percent humidity, measured at the device.
+ */
+ public BigDecimal ambientHumidityPercent;
+ }
+
+ public enum SDMHvacStatus {
+ OFF,
+ HEATING,
+ COOLING
+ }
+
+ public static class SDMResolution {
+ /**
+ * Maximum image resolution width.
+ */
+ public int width;
+
+ /**
+ * Maximum image resolution height.
+ */
+ public int height;
+ }
+
+ /**
+ * This trait belongs to any room for room-related information.
+ */
+ public static class SDMRoomInfoTrait extends SDMStructureTrait {
+ /**
+ * Custom name of the room. Corresponds to the name in the Google Home App.
+ */
+ public String customName;
+ }
+
+ /**
+ * This trait belongs to any structure for structure-related information.
+ */
+ public static class SDMStructureInfoTrait extends SDMStructureTrait {
+ /**
+ * Custom name of the structure. Corresponds to the name in the Google Home App.
+ */
+ public String customName;
+ }
+
+ public static class SDMStructureTrait extends SDMTrait {
+ }
+
+ public enum SDMTemperatureScale {
+ CELSIUS,
+ FAHRENHEIT;
+ }
+
+ /**
+ * This trait belongs to any device that has a sensor to measure temperature.
+ */
+ public static class SDMTemperatureTrait extends SDMDeviceTrait {
+ /**
+ * Temperature in degrees Celsius, measured at the device.
+ */
+ public BigDecimal ambientTemperatureCelsius;
+ }
+
+ /**
+ * This trait belongs to device types of THERMOSTAT that support ECO modes.
+ */
+ public static class SDMThermostatEcoTrait extends SDMThermostatTrait {
+ /**
+ * List of supported Eco modes.
+ */
+ public List<SDMThermostatEcoMode> availableModes;
+
+ /**
+ * The current Eco mode of the thermostat.
+ */
+ public SDMThermostatEcoMode mode;
+
+ /**
+ * Lowest temperature in Celsius at which the thermostat begins heating in Eco mode.
+ */
+ public BigDecimal heatCelsius;
+
+ /**
+ * Highest temperature in Celsius at which the thermostat begins cooling in Eco mode.
+ */
+ public BigDecimal coolCelsius;
+ }
+
+ /**
+ * This trait belongs to device types of THERMOSTAT that can report HVAC details.
+ */
+ public static class SDMThermostatHvacTrait extends SDMThermostatTrait {
+ /**
+ * Current HVAC status of the thermostat.
+ */
+ public SDMHvacStatus status;
+ }
+
+ public enum SDMThermostatMode {
+ HEAT,
+ COOL,
+ HEATCOOL,
+ OFF
+ }
+
+ /**
+ * This trait belongs to device types of THERMOSTAT that support different thermostat modes.
+ */
+ public static class SDMThermostatModeTrait extends SDMThermostatTrait {
+ /**
+ * List of supported thermostat modes.
+ */
+ public List<SDMThermostatMode> availableModes;
+
+ /**
+ * The current thermostat mode.
+ */
+ public SDMThermostatMode mode;
+ }
+
+ /**
+ * This trait belongs to device types of THERMOSTAT that support setting target temperature and temperature range.
+ */
+ public static class SDMThermostatTemperatureSetpointTrait extends SDMThermostatTrait {
+ /**
+ * Target temperature in Celsius for thermostat HEAT and HEATCOOL modes.
+ */
+ public BigDecimal heatCelsius;
+
+ /**
+ * Target temperature in Celsius for thermostat COOL and HEATCOOL modes.
+ */
+ public BigDecimal coolCelsius;
+ }
+
+ public static class SDMThermostatTrait extends SDMTrait {
+ }
+
+ public enum SDMFanTimerMode {
+ ON,
+ OFF
+ }
+
+ public static class SDMTrait {
+ }
+
+ @SerializedName("sdm.devices.traits.CameraEventImage")
+ public SDMCameraEventImageTrait cameraEventImage;
+
+ @SerializedName("sdm.devices.traits.CameraImage")
+ public SDMCameraImageTrait cameraImage;
+
+ @SerializedName("sdm.devices.traits.CameraLiveStream")
+ public SDMCameraLiveStreamTrait cameraLiveStream;
+
+ @SerializedName("sdm.devices.traits.CameraMotion")
+ public SDMCameraMotionTrait cameraMotion;
+
+ @SerializedName("sdm.devices.traits.CameraPerson")
+ public SDMCameraPersonTrait cameraPerson;
+
+ @SerializedName("sdm.devices.traits.CameraSound")
+ public SDMCameraSoundTrait cameraSound;
+
+ @SerializedName("sdm.devices.traits.Connectivity")
+ public SDMConnectivityTrait connectivity;
+
+ @SerializedName("sdm.devices.traits.DoorbellChime")
+ public SDMDoorbellChimeTrait doorbellChime;
+
+ @SerializedName("sdm.devices.traits.Fan")
+ public SDMFanTrait fan;
+
+ @SerializedName("sdm.devices.traits.Humidity")
+ public SDMHumidityTrait humidity;
+
+ @SerializedName("sdm.devices.traits.Info")
+ public SDMDeviceInfoTrait deviceInfo;
+
+ @SerializedName("sdm.devices.traits.Settings")
+ public SDMDeviceSettingsTrait deviceSettings;
+
+ @SerializedName("sdm.devices.traits.Temperature")
+ public SDMTemperatureTrait temperature;
+
+ @SerializedName("sdm.devices.traits.ThermostatEco")
+ public SDMThermostatEcoTrait thermostatEco;
+
+ @SerializedName("sdm.devices.traits.ThermostatHvac")
+ public SDMThermostatHvacTrait thermostatHvac;
+
+ @SerializedName("sdm.devices.traits.ThermostatMode")
+ public SDMThermostatModeTrait thermostatMode;
+
+ @SerializedName("sdm.devices.traits.ThermostatTemperatureSetpoint")
+ public SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint;
+
+ @SerializedName("sdm.structures.traits.Info")
+ public SDMStructureInfoTrait structureInfo;
+
+ @SerializedName("sdm.structures.traits.RoomInfo")
+ public SDMRoomInfoTrait roomInfo;
+
+ public <T> Stream<SDMTrait> traitStream() {
+ return Stream.of(cameraEventImage, cameraImage, cameraLiveStream, cameraMotion, cameraPerson, cameraSound,
+ connectivity, doorbellChime, fan, humidity, deviceInfo, deviceSettings, temperature, thermostatEco,
+ thermostatHvac, thermostatMode, thermostatTemperatureSetpoint, structureInfo, roomInfo)
+ .filter(Objects::nonNull);
+ }
+
+ public List<SDMTrait> traitList() {
+ return traitStream().collect(Collectors.toList());
+ }
+
+ public Set<SDMTrait> traitSet() {
+ return traitStream().collect(Collectors.toSet());
+ }
+
+ public void updateTraits(SDMTraits other) {
+ if (other.cameraEventImage != null) {
+ cameraEventImage = other.cameraEventImage;
+ }
+ if (other.cameraImage != null) {
+ cameraImage = other.cameraImage;
+ }
+ if (other.cameraLiveStream != null) {
+ cameraLiveStream = other.cameraLiveStream;
+ }
+ if (other.cameraMotion != null) {
+ cameraMotion = other.cameraMotion;
+ }
+ if (other.cameraPerson != null) {
+ cameraPerson = other.cameraPerson;
+ }
+ if (other.cameraSound != null) {
+ cameraSound = other.cameraSound;
+ }
+ if (other.connectivity != null) {
+ connectivity = other.connectivity;
+ }
+ if (other.doorbellChime != null) {
+ doorbellChime = other.doorbellChime;
+ }
+ if (other.fan != null) {
+ fan = other.fan;
+ }
+ if (other.humidity != null) {
+ humidity = other.humidity;
+ }
+ if (other.deviceInfo != null) {
+ deviceInfo = other.deviceInfo;
+ }
+ if (other.deviceSettings != null) {
+ deviceSettings = other.deviceSettings;
+ }
+ if (other.temperature != null) {
+ temperature = other.temperature;
+ }
+ if (other.thermostatEco != null) {
+ thermostatEco = other.thermostatEco;
+ }
+ if (other.thermostatHvac != null) {
+ thermostatHvac = other.thermostatHvac;
+ }
+ if (other.thermostatMode != null) {
+ thermostatMode = other.thermostatMode;
+ }
+ if (other.thermostatTemperatureSetpoint != null) {
+ thermostatTemperatureSetpoint = other.thermostatTemperatureSetpoint;
+ }
+ if (other.structureInfo != null) {
+ structureInfo = other.structureInfo;
+ }
+ if (other.roomInfo != null) {
+ roomInfo = other.roomInfo;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * An error occurred while sending data to the Pub/Sub REST API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class FailedSendingPubSubDataException extends Exception {
+
+ private static final long serialVersionUID = 8615651337708366903L;
+
+ public FailedSendingPubSubDataException(String message) {
+ super(message);
+ }
+
+ public FailedSendingPubSubDataException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public FailedSendingPubSubDataException(Throwable cause) {
+ super(cause);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * An error occurred while sending data to the SDM REST API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class FailedSendingSDMDataException extends Exception {
+
+ private static final long serialVersionUID = 5377279669017810297L;
+
+ public FailedSendingSDMDataException(String message) {
+ super(message);
+ }
+
+ public FailedSendingSDMDataException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public FailedSendingSDMDataException(Throwable cause) {
+ super(cause);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The OAuth 2.0 access token used with the Pub/Sub REST API is invalid and could not be refreshed.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidPubSubAccessTokenException extends Exception {
+
+ private static final long serialVersionUID = -2065751473657555846L;
+
+ public InvalidPubSubAccessTokenException(Exception cause) {
+ super(cause);
+ }
+
+ public InvalidPubSubAccessTokenException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public InvalidPubSubAccessTokenException(String message) {
+ super(message);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * An authorization code is invalid and cannot be used to obtain the OAuth 2.0 tokens used with the Pub/Sub REST API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidPubSubAuthorizationCodeException extends Exception {
+
+ private static final long serialVersionUID = 8422005071870179414L;
+
+ public InvalidPubSubAuthorizationCodeException(Exception cause) {
+ super(cause);
+ }
+
+ public InvalidPubSubAuthorizationCodeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public InvalidPubSubAuthorizationCodeException(String message) {
+ super(message);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The OAuth 2.0 access token used with the SDM REST API is invalid and could not be refreshed.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidSDMAccessTokenException extends Exception {
+
+ private static final long serialVersionUID = 6149230876422099759L;
+
+ public InvalidSDMAccessTokenException(Exception cause) {
+ super(cause);
+ }
+
+ public InvalidSDMAccessTokenException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public InvalidSDMAccessTokenException(String message) {
+ super(message);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * An authorization code is invalid and cannot be used to obtain the OAuth 2.0 tokens used with the SDM API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidSDMAuthorizationCodeException extends Exception {
+
+ private static final long serialVersionUID = -8900246112957957403L;
+
+ public InvalidSDMAuthorizationCodeException(Exception cause) {
+ super(cause);
+ }
+
+ public InvalidSDMAuthorizationCodeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public InvalidSDMAuthorizationCodeException(String message) {
+ super(message);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.handler;
+
+import static java.util.function.Predicate.not;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMGson.GSON;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+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.openhab.binding.nest.internal.sdm.api.PubSubAPI;
+import org.openhab.binding.nest.internal.sdm.api.SDMAPI;
+import org.openhab.binding.nest.internal.sdm.config.SDMAccountConfiguration;
+import org.openhab.binding.nest.internal.sdm.discovery.SDMDiscoveryService;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubMessage;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMResourceUpdate;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingPubSubDataException;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidPubSubAccessTokenException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidPubSubAuthorizationCodeException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAuthorizationCodeException;
+import org.openhab.binding.nest.internal.sdm.listener.PubSubSubscriptionListener;
+import org.openhab.binding.nest.internal.sdm.listener.SDMAPIRequestListener;
+import org.openhab.binding.nest.internal.sdm.listener.SDMEventListener;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SDMAccountHandler} provides the {@link SDMAPI} instance used by the device handlers.
+ * The {@link SDMAPI} is used by device handlers for periodically refreshing device data and sending device commands.
+ * When Pub/Sub is properly configured, the account handler also sends received {@link SDMEvent}s from the
+ * {@link PubSubAPI} to the subscribed {@link SDMEventListener}s.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMAccountHandler extends BaseBridgeHandler {
+
+ private static final String PUBSUB_TOPIC_NAME_PREFIX = "projects/sdm-prod/topics/enterprise-";
+
+ private final Logger logger = LoggerFactory.getLogger(SDMAccountHandler.class);
+
+ private HttpClientFactory httpClientFactory;
+ private OAuthFactory oAuthFactory;
+
+ private @NonNullByDefault({}) SDMAccountConfiguration config;
+ private @Nullable Future<?> initializeFuture;
+
+ private @Nullable PubSubAPI pubSubAPI;
+ private @Nullable Exception pubSubException;
+
+ private @Nullable SDMAPI sdmAPI;
+ private @Nullable Exception sdmException;
+ private @Nullable Future<?> sdmCheckFuture;
+ private final Duration sdmCheckDelay = Duration.ofMinutes(1);
+
+ private final Map<String, SDMEventListener> listeners = new ConcurrentHashMap<>();
+
+ private final SDMAPIRequestListener requestListener = new SDMAPIRequestListener() {
+ @Override
+ public void onError(Exception exception) {
+ sdmException = exception;
+ logger.debug("SDM exception occurred");
+ updateThingStatus();
+
+ Future<?> future = sdmCheckFuture;
+ if (future == null || future.isDone()) {
+ sdmCheckFuture = scheduler.scheduleWithFixedDelay(() -> {
+ SDMAPI localSDMAPI = sdmAPI;
+ if (localSDMAPI != null) {
+ try {
+ logger.debug("Checking SDM API");
+ localSDMAPI.listDevices();
+ } catch (FailedSendingSDMDataException | InvalidSDMAccessTokenException e) {
+ logger.debug("SDM API check failed");
+ }
+ }
+ }, sdmCheckDelay.toNanos(), sdmCheckDelay.toNanos(), TimeUnit.NANOSECONDS);
+ logger.debug("Scheduled SDM API check job");
+ }
+ }
+
+ @Override
+ public void onSuccess() {
+ if (sdmException != null) {
+ sdmException = null;
+ logger.debug("SDM exception cleared");
+ updateThingStatus();
+ }
+
+ Future<?> future = sdmCheckFuture;
+ if (future != null) {
+ future.cancel(true);
+ sdmCheckFuture = null;
+ logger.debug("Cancelled SDM API check job");
+ }
+ }
+ };
+
+ private final PubSubSubscriptionListener subscriptionListener = new PubSubSubscriptionListener() {
+ @Override
+ public void onError(Exception exception) {
+ pubSubException = exception;
+ logger.debug("Pub/Sub exception occurred");
+ updateThingStatus();
+ }
+
+ @Override
+ public void onMessage(PubSubMessage message) {
+ if (pubSubException != null) {
+ pubSubException = null;
+ logger.debug("Pub/Sub exception cleared");
+ updateThingStatus();
+ }
+ handlePubSubMessage(message);
+ }
+
+ @Override
+ public void onNoNewMessages() {
+ if (pubSubException != null) {
+ pubSubException = null;
+ logger.debug("Pub/Sub exception cleared");
+ updateThingStatus();
+ }
+ }
+ };
+
+ public SDMAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory, OAuthFactory oAuthFactory) {
+ super(bridge);
+ this.httpClientFactory = httpClientFactory;
+ this.oAuthFactory = oAuthFactory;
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ }
+
+ @Override
+ public void initialize() {
+ config = getConfigAs(SDMAccountConfiguration.class);
+
+ updateStatus(ThingStatus.UNKNOWN);
+
+ initializeFuture = scheduler.submit(() -> {
+ sdmAPI = initializeSDMAPI();
+ if (config.usePubSub()) {
+ pubSubAPI = initializePubSubAPI();
+ }
+ updateThingStatus();
+ });
+ }
+
+ private @Nullable SDMAPI initializeSDMAPI() {
+ SDMAPI sdmAPI = new SDMAPI(httpClientFactory, oAuthFactory, getThing().getUID().getAsString(),
+ config.sdmProjectId, config.sdmClientId, config.sdmClientSecret);
+ sdmException = null;
+
+ try {
+ if (!config.sdmAuthorizationCode.isBlank()) {
+ sdmAPI.authorizeClient(config.sdmAuthorizationCode);
+
+ Configuration configuration = editConfiguration();
+ configuration.put(SDMAccountConfiguration.SDM_AUTHORIZATION_CODE, "");
+ updateConfiguration(configuration);
+ }
+
+ sdmAPI.checkAccessTokenValidity();
+ sdmAPI.addRequestListener(requestListener);
+
+ return sdmAPI;
+ } catch (InvalidSDMAccessTokenException | InvalidSDMAuthorizationCodeException | IOException e) {
+ sdmException = e;
+ return null;
+ }
+ }
+
+ private @Nullable PubSubAPI initializePubSubAPI() {
+ PubSubAPI pubSubAPI = new PubSubAPI(httpClientFactory, oAuthFactory, getThing().getUID().getAsString(),
+ config.pubsubProjectId, config.pubsubClientId, config.pubsubClientSecret);
+ pubSubException = null;
+
+ try {
+ if (!config.pubsubAuthorizationCode.isBlank()) {
+ pubSubAPI.authorizeClient(config.pubsubAuthorizationCode);
+
+ Configuration configuration = editConfiguration();
+ configuration.put(SDMAccountConfiguration.PUBSUB_AUTHORIZATION_CODE, "");
+ updateConfiguration(configuration);
+ }
+
+ pubSubAPI.checkAccessTokenValidity();
+ pubSubAPI.createSubscription(config.pubsubSubscriptionId, PUBSUB_TOPIC_NAME_PREFIX + config.sdmProjectId);
+ pubSubAPI.addSubscriptionListener(config.pubsubSubscriptionId, subscriptionListener);
+
+ return pubSubAPI;
+ } catch (FailedSendingPubSubDataException | InvalidPubSubAccessTokenException
+ | InvalidPubSubAuthorizationCodeException | IOException e) {
+ pubSubException = e;
+ return null;
+ }
+ }
+
+ @Override
+ public void dispose() {
+ Future<?> localFuture = initializeFuture;
+ if (localFuture != null) {
+ localFuture.cancel(true);
+ initializeFuture = null;
+ }
+
+ localFuture = sdmCheckFuture;
+ if (localFuture != null) {
+ localFuture.cancel(true);
+ sdmCheckFuture = null;
+ }
+
+ PubSubAPI localPubSubAPI = pubSubAPI;
+ if (localPubSubAPI != null) {
+ localPubSubAPI.dispose();
+ pubSubAPI = null;
+ }
+
+ SDMAPI localSDMAPI = sdmAPI;
+ if (localSDMAPI != null) {
+ localSDMAPI.dispose();
+ sdmAPI = null;
+ }
+ }
+
+ @Override
+ public Collection<Class<? extends ThingHandlerService>> getServices() {
+ return List.of(SDMDiscoveryService.class);
+ }
+
+ public void addThingDataListener(String deviceId, SDMEventListener listener) {
+ listeners.put(deviceId, listener);
+ }
+
+ public void removeThingDataListener(String deviceId, SDMEventListener listener) {
+ listeners.remove(deviceId, listener);
+ }
+
+ public @Nullable SDMAPI getAPI() {
+ return sdmAPI;
+ }
+
+ private void handlePubSubMessage(PubSubMessage message) {
+ String messageId = message.messageId;
+ String json = new String(Base64.getDecoder().decode(message.data), StandardCharsets.UTF_8);
+
+ logger.debug("Handling messageId={} with content:", messageId);
+ logger.debug("{}", json);
+
+ SDMEvent event = GSON.fromJson(json, SDMEvent.class);
+ if (event == null) {
+ logger.debug("Ignoring messageId={} (empty)", messageId);
+ return;
+ }
+
+ SDMResourceUpdate resourceUpdate = event.resourceUpdate;
+ if (resourceUpdate == null) {
+ logger.debug("Ignoring messageId={} (no resource update)", messageId);
+ return;
+ }
+
+ String deviceId = resourceUpdate.name.deviceId;
+ SDMEventListener listener = listeners.get(deviceId);
+ if (listener != null) {
+ logger.debug("Sending messageId={} to listener with deviceId={}", messageId, deviceId);
+ listener.onEvent(event);
+ } else {
+ logger.debug("No listener for messageId={} with deviceId={}", messageId, deviceId);
+ }
+ }
+
+ private void updateThingStatus() {
+ Exception e = sdmException != null ? sdmException : pubSubException;
+ if (e != null) {
+ if (e instanceof InvalidSDMAccessTokenException || e instanceof InvalidSDMAuthorizationCodeException
+ || e instanceof InvalidPubSubAccessTokenException
+ || e instanceof InvalidPubSubAuthorizationCodeException) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ } else {
+ Throwable cause = e.getCause();
+ String description = Stream
+ .of(Objects.requireNonNullElse(e.getMessage(), ""),
+ cause == null ? "" : Objects.requireNonNullElse(cause.getMessage(), ""))
+ .filter(not(String::isBlank)) //
+ .collect(Collectors.joining(": "));
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, description);
+ }
+ } else {
+ String description = config.usePubSub() ? "Using periodic refresh and Pub/Sub" : "Using periodic refresh";
+ updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, description);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.handler;
+
+import static org.openhab.core.thing.ThingStatus.*;
+
+import java.time.ZonedDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.sdm.SDMBindingConstants;
+import org.openhab.binding.nest.internal.sdm.api.SDMAPI;
+import org.openhab.binding.nest.internal.sdm.config.SDMDeviceConfiguration;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCommandRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCommandResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMDevice;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMResourceUpdate;
+import org.openhab.binding.nest.internal.sdm.dto.SDMIdentifiable;
+import org.openhab.binding.nest.internal.sdm.dto.SDMParentRelation;
+import org.openhab.binding.nest.internal.sdm.dto.SDMResourceName.SDMResourceNameType;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMCameraImageTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMCameraLiveStreamTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMConnectivityStatus;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMConnectivityTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMDeviceInfoTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMDeviceSettingsTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMResolution;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
+import org.openhab.binding.nest.internal.sdm.listener.SDMEventListener;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SDMBaseHandler} provides the common functionality of all SDM device thing handlers.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public abstract class SDMBaseHandler extends BaseThingHandler implements SDMIdentifiable, SDMEventListener {
+
+ private final Logger logger = LoggerFactory.getLogger(SDMBaseHandler.class);
+
+ protected @NonNullByDefault({}) SDMDeviceConfiguration config;
+ protected SDMDevice device = new SDMDevice();
+ protected String deviceId = "";
+ protected @Nullable ZonedDateTime lastRefreshDateTime;
+ protected @Nullable ScheduledFuture<?> refreshJob;
+ protected final TimeZoneProvider timeZoneProvider;
+
+ public SDMBaseHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
+ super(thing);
+ this.timeZoneProvider = timeZoneProvider;
+ }
+
+ @Override
+ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+ updateBridgeStatus();
+ }
+
+ /**
+ * Updates the thing state based on that of the bridge.
+ */
+ protected void updateBridgeStatus() {
+ Bridge bridge = getBridge();
+ ThingStatus bridgeStatus = bridge != null ? bridge.getStatus() : null;
+ if (bridge == null) {
+ disableRefresh();
+ updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge configured");
+ } else if (bridgeStatus == ONLINE && thing.getStatus() != ONLINE) {
+ enableRefresh();
+ } else if (bridgeStatus == OFFLINE) {
+ disableRefresh();
+ updateStatus(OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ } else if (bridgeStatus == UNKNOWN) {
+ disableRefresh();
+ updateStatus(UNKNOWN);
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ delayedRefresh();
+ }
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing handler for {}", thing.getUID());
+ config = getConfigAs(SDMDeviceConfiguration.class);
+ deviceId = config.deviceId;
+ updateStatus(ThingStatus.UNKNOWN);
+ updateBridgeStatus();
+ }
+
+ @Override
+ public void dispose() {
+ disableRefresh();
+ }
+
+ @Override
+ public String getId() {
+ return deviceId;
+ }
+
+ protected @Nullable SDMAccountHandler getAccountHandler() {
+ Bridge bridge = getBridge();
+ return bridge != null ? (SDMAccountHandler) bridge.getHandler() : null;
+ }
+
+ protected @Nullable SDMAPI getAPI() {
+ SDMAccountHandler accountHandler = getAccountHandler();
+ return accountHandler != null ? accountHandler.getAPI() : null;
+ }
+
+ protected @Nullable SDMDevice getDeviceInfo() throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ SDMAPI api = getAPI();
+ return api == null ? null : api.getDevice(deviceId);
+ }
+
+ protected <T extends SDMCommandResponse> @Nullable T executeDeviceCommand(SDMCommandRequest<T> request)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ SDMAPI api = getAPI();
+ return api == null ? null : api.executeDeviceCommand(deviceId, request);
+ }
+
+ protected @Nullable SDMTraits getTraitsForUpdate(SDMEvent event) {
+ SDMResourceUpdate resourceUpdate = event.resourceUpdate;
+ if (resourceUpdate == null) {
+ return null;
+ }
+
+ SDMTraits traits = resourceUpdate.traits;
+ if (traits == null) {
+ return null;
+ }
+
+ ZonedDateTime localRefreshDateTime = lastRefreshDateTime;
+ if (localRefreshDateTime == null || event.timestamp.isBefore(localRefreshDateTime)) {
+ return null;
+ }
+
+ return traits;
+ }
+
+ @Override
+ public void onEvent(SDMEvent event) {
+ SDMTraits traits = getTraitsForUpdate(event);
+ if (traits != null) {
+ logger.debug("Updating traits using resource update traits in event");
+ device.traits.updateTraits(traits);
+ }
+ }
+
+ protected void refreshDevice() {
+ try {
+ SDMDevice localDevice = getDeviceInfo();
+ if (localDevice == null) {
+ logger.debug("Cannot refresh device (empty response or handler has no bridge)");
+ return;
+ }
+
+ this.device = localDevice;
+ this.lastRefreshDateTime = ZonedDateTime.now();
+
+ Map<String, String> properties = editProperties();
+ properties.putAll(getDeviceProperties(localDevice));
+ updateProperties(properties);
+
+ updateStateWithTraits(localDevice.traits);
+ } catch (InvalidSDMAccessTokenException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ } catch (FailedSendingSDMDataException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ }
+ }
+
+ protected void updateStateWithTraits(SDMTraits traits) {
+ SDMConnectivityTrait connectivity = traits.connectivity;
+ if (connectivity == null && device.traits.connectivity != null) {
+ logger.debug("Skipping partial update for device with connectivity trait");
+ return;
+ }
+
+ ThingStatus thingStatus = connectivity == null || connectivity.status == null
+ || connectivity.status == SDMConnectivityStatus.ONLINE ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
+
+ if (thing.getStatus() != thingStatus) {
+ updateStatus(thingStatus);
+ }
+ }
+
+ protected void enableRefresh() {
+ scheduleRefreshJob();
+ SDMAccountHandler handler = getAccountHandler();
+ if (handler != null) {
+ handler.addThingDataListener(getId(), this);
+ }
+ }
+
+ protected void disableRefresh() {
+ cancelRefreshJob();
+ SDMAccountHandler handler = getAccountHandler();
+ if (handler != null) {
+ handler.removeThingDataListener(getId(), this);
+ }
+ }
+
+ protected void cancelRefreshJob() {
+ ScheduledFuture<?> localRefreshJob = refreshJob;
+ if (localRefreshJob != null && !localRefreshJob.isCancelled()) {
+ localRefreshJob.cancel(true);
+ }
+ }
+
+ protected void scheduleRefreshJob() {
+ ScheduledFuture<?> localRefreshJob = refreshJob;
+ if (localRefreshJob == null || localRefreshJob.isCancelled()) {
+ refreshJob = scheduler.scheduleWithFixedDelay(this::refreshDevice, 0, config.refreshInterval,
+ TimeUnit.SECONDS);
+ }
+ }
+
+ protected void delayedRefresh() {
+ cancelRefreshJob();
+ refreshJob = scheduler.scheduleWithFixedDelay(this::refreshDevice, 3, config.refreshInterval, TimeUnit.SECONDS);
+ }
+
+ public static Map<String, String> getDeviceProperties(SDMDevice device) {
+ Map<String, String> properties = new HashMap<>();
+
+ SDMTraits traits = device.traits;
+
+ SDMDeviceInfoTrait deviceInfo = traits.deviceInfo;
+ if (deviceInfo != null && !deviceInfo.customName.isBlank()) {
+ properties.put(SDMBindingConstants.PROPERTY_CUSTOM_NAME, deviceInfo.customName);
+ }
+
+ List<SDMParentRelation> parentRelations = device.parentRelations;
+ for (SDMParentRelation parentRelation : parentRelations) {
+ if (parentRelation.parent.type == SDMResourceNameType.ROOM && !parentRelation.displayName.isBlank()) {
+ properties.put(SDMBindingConstants.PROPERTY_ROOM, parentRelation.displayName);
+ break;
+ }
+ }
+
+ SDMDeviceSettingsTrait deviceSettings = traits.deviceSettings;
+ if (deviceSettings != null) {
+ properties.put(SDMBindingConstants.PROPERTY_TEMPERATURE_SCALE, deviceSettings.temperatureScale.name());
+ }
+
+ SDMCameraImageTrait cameraImage = traits.cameraImage;
+ if (cameraImage != null) {
+ SDMResolution resolution = cameraImage.maxImageResolution;
+ properties.put(SDMBindingConstants.PROPERTY_MAX_IMAGE_RESOLUTION,
+ String.format("%sx%s", resolution.width, resolution.height));
+ }
+
+ SDMCameraLiveStreamTrait cameraLiveStream = traits.cameraLiveStream;
+ if (cameraLiveStream != null) {
+ List<String> audioCodecs = cameraLiveStream.audioCodecs;
+ if (audioCodecs != null) {
+ properties.put(SDMBindingConstants.PROPERTY_AUDIO_CODECS,
+ audioCodecs.stream().collect(Collectors.joining(", ")));
+ }
+
+ SDMResolution maxVideoResolution = cameraLiveStream.maxVideoResolution;
+ if (maxVideoResolution != null) {
+ SDMResolution resolution = maxVideoResolution;
+ properties.put(SDMBindingConstants.PROPERTY_MAX_VIDEO_RESOLUTION,
+ String.format("%sx%s", resolution.width, resolution.height));
+ }
+
+ List<String> supportedProtocols = cameraLiveStream.supportedProtocols;
+ if (supportedProtocols != null) {
+ properties.put(SDMBindingConstants.PROPERTY_SUPPORTED_PROTOCOLS,
+ supportedProtocols.stream().collect(Collectors.joining(", ")));
+ }
+
+ List<String> videoCodecs = cameraLiveStream.videoCodecs;
+ if (videoCodecs != null) {
+ properties.put(SDMBindingConstants.PROPERTY_VIDEO_CODECS,
+ videoCodecs.stream().collect(Collectors.joining(", ")));
+ }
+ }
+
+ return properties;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.handler;
+
+import static org.openhab.binding.nest.internal.sdm.SDMBindingConstants.*;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageRequest.EVENT_IMAGE_VALIDITY;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.sdm.SDMBindingConstants;
+import org.openhab.binding.nest.internal.sdm.api.SDMAPI;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageResults;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraRtspStreamRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraRtspStreamResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraRtspStreamResults;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMDeviceEvent;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMResourceUpdate;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMResourceUpdateEvents;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SDMCameraHandler} handles state updates of SDM devices with a camera.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMCameraHandler extends SDMBaseHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(SDMCameraHandler.class);
+
+ private @Nullable ZonedDateTime lastChimeEventTimestamp;
+ private @Nullable ZonedDateTime lastMotionEventTimestamp;
+ private @Nullable ZonedDateTime lastPersonEventTimestamp;
+ private @Nullable ZonedDateTime lastSoundEventTimestamp;
+
+ public SDMCameraHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
+ super(thing, timeZoneProvider);
+ }
+
+ private void updateLiveStreamChannels() throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ boolean channelLinked = Stream.of(CHANNEL_LIVE_STREAM_CURRENT_TOKEN, CHANNEL_LIVE_STREAM_EXPIRATION_TIMESTAMP,
+ CHANNEL_LIVE_STREAM_EXTENSION_TOKEN, CHANNEL_LIVE_STREAM_URL).anyMatch(this::isLinked);
+ if (!channelLinked) {
+ logger.debug("Not updating live stream channels (channels are not linked)");
+ return;
+ }
+
+ logger.debug("Updating live stream channels");
+
+ SDMGenerateCameraRtspStreamResponse response = executeDeviceCommand(new SDMGenerateCameraRtspStreamRequest());
+ if (response == null) {
+ logger.debug("Cannot update live stream channels (empty response)");
+ return;
+ }
+
+ SDMGenerateCameraRtspStreamResults results = response.results;
+ if (results != null) {
+ updateState(CHANNEL_LIVE_STREAM_CURRENT_TOKEN, new StringType(results.streamToken));
+ updateState(CHANNEL_LIVE_STREAM_EXPIRATION_TIMESTAMP,
+ new DateTimeType(results.expiresAt.withZoneSameInstant(timeZoneProvider.getTimeZone())));
+ updateState(CHANNEL_LIVE_STREAM_EXTENSION_TOKEN, new StringType(results.streamExtensionToken));
+ updateState(CHANNEL_LIVE_STREAM_URL, new StringType(results.streamUrls.rtspUrl));
+ }
+ }
+
+ @Override
+ public void onEvent(SDMEvent event) {
+ super.onEvent(event);
+
+ SDMResourceUpdate resourceUpdate = event.resourceUpdate;
+ if (resourceUpdate == null) {
+ logger.debug("Skipping event without resource update");
+ return;
+ }
+
+ SDMResourceUpdateEvents events = resourceUpdate.events;
+ if (events == null) {
+ logger.debug("Skipping resource update without events");
+ return;
+ }
+
+ try {
+ SDMDeviceEvent deviceEvent = events.cameraMotionEvent;
+ if (deviceEvent != null) {
+ lastMotionEventTimestamp = updateImageChannelsForEvent(CHANNEL_MOTION_EVENT_TIMESTAMP,
+ CHANNEL_MOTION_EVENT_IMAGE, lastMotionEventTimestamp, event.timestamp, deviceEvent);
+ }
+
+ deviceEvent = events.cameraPersonEvent;
+ if (deviceEvent != null) {
+ lastPersonEventTimestamp = updateImageChannelsForEvent(CHANNEL_PERSON_EVENT_TIMESTAMP,
+ CHANNEL_PERSON_EVENT_IMAGE, lastPersonEventTimestamp, event.timestamp, deviceEvent);
+ }
+
+ deviceEvent = events.cameraSoundEvent;
+ if (deviceEvent != null) {
+ lastSoundEventTimestamp = updateImageChannelsForEvent(CHANNEL_SOUND_EVENT_TIMESTAMP,
+ CHANNEL_SOUND_EVENT_IMAGE, lastSoundEventTimestamp, event.timestamp, deviceEvent);
+ }
+
+ deviceEvent = events.doorbellChimeEvent;
+ if (deviceEvent != null) {
+ lastChimeEventTimestamp = updateImageChannelsForEvent(CHANNEL_CHIME_EVENT_TIMESTAMP,
+ CHANNEL_CHIME_EVENT_IMAGE, lastChimeEventTimestamp, event.timestamp, deviceEvent);
+ }
+ } catch (FailedSendingSDMDataException | InvalidSDMAccessTokenException e) {
+ logger.warn("Handling SDM event failed for {}", thing.getUID(), e);
+ }
+ }
+
+ private @Nullable ZonedDateTime updateImageChannelsForEvent(String timeChannelName, String imageChannelName,
+ @Nullable ZonedDateTime lastEventTimestamp, ZonedDateTime eventTimestamp, SDMDeviceEvent event)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ boolean newerEvent = lastEventTimestamp == null || lastEventTimestamp.isBefore(eventTimestamp);
+ if (!newerEvent) {
+ logger.debug("Skipping {} channel update (more recent event already occurred)", imageChannelName);
+ return lastEventTimestamp;
+ }
+
+ if (!isLinked(imageChannelName)) {
+ logger.debug("Not downloading image for {} channel update (channel is not linked)", imageChannelName);
+ } else if (Duration.between(eventTimestamp, ZonedDateTime.now()).compareTo(EVENT_IMAGE_VALIDITY) > 0) {
+ logger.debug("Cannot download image for {} channel update (event image has expired)", imageChannelName);
+ updateState(timeChannelName, UnDefType.NULL);
+ } else {
+ BigDecimal imageWidth = null;
+ BigDecimal imageHeight = null;
+
+ Channel channel = getThing().getChannel(imageChannelName);
+ if (channel != null) {
+ Configuration configuration = channel.getConfiguration();
+ imageWidth = (BigDecimal) configuration.get(SDMBindingConstants.CONFIG_PROPERTY_IMAGE_WIDTH);
+ imageHeight = (BigDecimal) configuration.get(SDMBindingConstants.CONFIG_PROPERTY_IMAGE_HEIGHT);
+ }
+
+ updateState(imageChannelName, getCameraImage(event.eventId, imageWidth, imageHeight));
+ }
+
+ updateState(timeChannelName,
+ new DateTimeType(eventTimestamp.withZoneSameInstant(timeZoneProvider.getTimeZone())));
+
+ logger.debug("Updated {} channel and {} with image of event at {}", imageChannelName, timeChannelName,
+ eventTimestamp);
+
+ updateLiveStreamChannels();
+
+ return eventTimestamp;
+ }
+
+ private State getCameraImage(String eventId, @Nullable BigDecimal imageWidth, @Nullable BigDecimal imageHeight)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ SDMGenerateCameraImageResponse response = executeDeviceCommand(new SDMGenerateCameraImageRequest(eventId));
+ if (response == null) {
+ logger.debug("Cannot get image for camera event (empty response)");
+ return UnDefType.NULL;
+ }
+
+ SDMGenerateCameraImageResults results = response.results;
+ if (results == null) {
+ logger.debug("Cannot get image for camera event (no results)");
+ return UnDefType.NULL;
+ }
+
+ SDMAPI api = getAPI();
+ if (api == null) {
+ logger.debug("Cannot get image for camera event (handler has no bridge)");
+ return UnDefType.NULL;
+ }
+
+ byte[] imageBytes = api.getCameraImage(results.url, results.token, imageWidth, imageHeight);
+ return new RawType(imageBytes, "image/jpeg");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.handler;
+
+import static org.openhab.binding.nest.internal.sdm.SDMBindingConstants.*;
+import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
+import static org.openhab.core.library.unit.SIUnits.CELSIUS;
+import static org.openhab.core.library.unit.Units.PERCENT;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.sdm.SDMBindingConstants;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetFanTimerRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatCoolSetpointRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatEcoModeRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatHeatSetpointRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatModeRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatRangeSetpointRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMDeviceSettingsTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMFanTimerMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMFanTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMHumidityTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMTemperatureTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatEcoMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatEcoTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatHvacTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatModeTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatTemperatureSetpointTrait;
+import org.openhab.binding.nest.internal.sdm.exception.FailedSendingSDMDataException;
+import org.openhab.binding.nest.internal.sdm.exception.InvalidSDMAccessTokenException;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+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;
+
+/**
+ * The {@link SDMThermostatHandler} handles state updates and commands for SDM thermostat devices.
+ *
+ * @author Brian Higginbotham - Initial contribution
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMThermostatHandler extends SDMBaseHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(SDMThermostatHandler.class);
+
+ public SDMThermostatHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
+ super(thing, timeZoneProvider);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ try {
+ if (command instanceof RefreshType) {
+ delayedRefresh();
+ } else if (CHANNEL_CURRENT_ECO_MODE.equals(channelUID.getId())) {
+ if (command instanceof StringType) {
+ SDMThermostatEcoMode mode = SDMThermostatEcoMode.valueOf(command.toString());
+ executeDeviceCommand(new SDMSetThermostatEcoModeRequest(mode));
+ delayedRefresh();
+ }
+ } else if (CHANNEL_CURRENT_MODE.equals(channelUID.getId())) {
+ if (command instanceof StringType) {
+ SDMThermostatMode mode = SDMThermostatMode.valueOf(command.toString());
+ executeDeviceCommand(new SDMSetThermostatModeRequest(mode));
+ delayedRefresh();
+ }
+ } else if (CHANNEL_FAN_TIMER_MODE.equals(channelUID.getId())) {
+ if (command instanceof OnOffType) {
+ if ((OnOffType) command == OnOffType.ON) {
+ executeDeviceCommand(new SDMSetFanTimerRequest(SDMFanTimerMode.ON, getFanTimerDuration()));
+ } else {
+ executeDeviceCommand(new SDMSetFanTimerRequest(SDMFanTimerMode.OFF));
+ }
+ delayedRefresh();
+ }
+ } else if (CHANNEL_FAN_TIMER_TIMEOUT.equals(channelUID.getId())) {
+ if (command instanceof DateTimeType) {
+ Duration duration = Duration.between(ZonedDateTime.now(),
+ ((DateTimeType) command).getZonedDateTime());
+ executeDeviceCommand(new SDMSetFanTimerRequest(SDMFanTimerMode.ON, duration));
+ delayedRefresh();
+ }
+ } else if (CHANNEL_MAXIMUM_TEMPERATURE.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ BigDecimal minTemperature = getMinTemperature();
+ if (minTemperature != null) {
+ setTargetTemperature(new QuantityType<>(minTemperature, CELSIUS),
+ (QuantityType<Temperature>) command);
+ delayedRefresh();
+ }
+ }
+ } else if (CHANNEL_MINIMUM_TEMPERATURE.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ BigDecimal maxTemperature = getMaxTemperature();
+ if (maxTemperature != null) {
+ setTargetTemperature((QuantityType<Temperature>) command,
+ new QuantityType<>(maxTemperature, CELSIUS));
+ delayedRefresh();
+ }
+ }
+ } else if (CHANNEL_TARGET_TEMPERATURE.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ setTargetTemperature((QuantityType<Temperature>) command);
+ delayedRefresh();
+ }
+ }
+ } catch (FailedSendingSDMDataException | InvalidSDMAccessTokenException e) {
+ logger.debug("Exception while handling {} command for {}: {}", command, thing.getUID(), e.getMessage());
+ }
+ }
+
+ @Override
+ protected void updateStateWithTraits(SDMTraits traits) {
+ logger.debug("Refreshing channels for: {}", thing.getUID());
+ super.updateStateWithTraits(traits);
+
+ SDMHumidityTrait humidity = traits.humidity;
+ if (humidity != null) {
+ updateState(CHANNEL_AMBIENT_HUMIDITY, new QuantityType<>(humidity.ambientHumidityPercent, PERCENT));
+ }
+
+ SDMTemperatureTrait temperature = traits.temperature;
+ if (temperature != null) {
+ updateState(CHANNEL_AMBIENT_TEMPERATURE, temperatureToState(temperature.ambientTemperatureCelsius));
+ }
+
+ SDMThermostatModeTrait thermostatMode = traits.thermostatMode;
+ if (thermostatMode != null) {
+ updateState(CHANNEL_CURRENT_MODE, new StringType(thermostatMode.mode.name()));
+ }
+
+ SDMThermostatEcoTrait thermostatEco = traits.thermostatEco;
+ if (thermostatEco != null) {
+ updateState(CHANNEL_CURRENT_ECO_MODE, new StringType(thermostatEco.mode.name()));
+ }
+
+ SDMFanTrait fan = traits.fan;
+ if (fan != null) {
+ updateState(CHANNEL_FAN_TIMER_MODE, fan.timerMode == SDMFanTimerMode.ON ? OnOffType.ON : OnOffType.OFF);
+ updateState(CHANNEL_FAN_TIMER_TIMEOUT, fan.timerTimeout == null ? UnDefType.NULL
+ : new DateTimeType(fan.timerTimeout.withZoneSameInstant(timeZoneProvider.getTimeZone())));
+ }
+
+ SDMThermostatHvacTrait thermostatHvac = traits.thermostatHvac;
+ if (thermostatHvac != null) {
+ updateState(CHANNEL_HVAC_STATUS, new StringType(thermostatHvac.status.name()));
+ }
+
+ BigDecimal maxTemperature = getMaxTemperature();
+ if (maxTemperature != null) {
+ updateState(CHANNEL_MAXIMUM_TEMPERATURE, temperatureToState(getMaxTemperature()));
+ }
+
+ BigDecimal minTemperature = getMinTemperature();
+ if (minTemperature != null) {
+ updateState(CHANNEL_MINIMUM_TEMPERATURE, temperatureToState(minTemperature));
+ }
+
+ BigDecimal targetTemperature = getTargetTemperature();
+ if (targetTemperature != null) {
+ updateState(CHANNEL_TARGET_TEMPERATURE, temperatureToState(targetTemperature));
+ }
+ }
+
+ private Duration getFanTimerDuration() {
+ long seconds = 900;
+
+ Channel channel = getThing().getChannel(SDMBindingConstants.CHANNEL_FAN_TIMER_MODE);
+ if (channel != null) {
+ Configuration configuration = channel.getConfiguration();
+ Object fanTimerDuration = configuration.get(SDMBindingConstants.CONFIG_PROPERTY_FAN_TIMER_DURATION);
+ if (fanTimerDuration instanceof BigDecimal) {
+ seconds = ((BigDecimal) fanTimerDuration).longValue();
+ }
+ }
+
+ return Duration.ofSeconds(seconds);
+ }
+
+ private @Nullable BigDecimal getMinTemperature() {
+ SDMThermostatEcoTrait thermostatEco = device.traits.thermostatEco;
+ if (thermostatEco != null && thermostatEco.mode == SDMThermostatEcoMode.MANUAL_ECO) {
+ return thermostatEco.heatCelsius;
+ }
+
+ SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint = device.traits.thermostatTemperatureSetpoint;
+ SDMThermostatModeTrait thermostatMode = device.traits.thermostatMode;
+ if (thermostatMode != null && thermostatMode.mode == SDMThermostatMode.HEATCOOL) {
+ return thermostatTemperatureSetpoint.heatCelsius;
+ }
+
+ return null;
+ }
+
+ private @Nullable BigDecimal getMaxTemperature() {
+ SDMThermostatEcoTrait thermostatEco = device.traits.thermostatEco;
+ if (thermostatEco != null && thermostatEco.mode == SDMThermostatEcoMode.MANUAL_ECO) {
+ return thermostatEco.coolCelsius;
+ }
+
+ SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint = device.traits.thermostatTemperatureSetpoint;
+ SDMThermostatModeTrait thermostatMode = device.traits.thermostatMode;
+ if (thermostatMode != null && thermostatMode.mode == SDMThermostatMode.HEATCOOL) {
+ return thermostatTemperatureSetpoint.coolCelsius;
+ }
+
+ return null;
+ }
+
+ private @Nullable BigDecimal getTargetTemperature() {
+ SDMThermostatEcoTrait thermostatEco = device.traits.thermostatEco;
+ if (thermostatEco != null && thermostatEco.mode == SDMThermostatEcoMode.MANUAL_ECO) {
+ return null;
+ }
+
+ SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint = device.traits.thermostatTemperatureSetpoint;
+ SDMThermostatModeTrait thermostatMode = device.traits.thermostatMode;
+ if (thermostatMode != null) {
+ if (thermostatMode.mode == SDMThermostatMode.COOL) {
+ return thermostatTemperatureSetpoint.coolCelsius;
+ }
+ if (thermostatMode.mode == SDMThermostatMode.HEAT) {
+ return thermostatTemperatureSetpoint.heatCelsius;
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ public void onEvent(SDMEvent event) {
+ super.onEvent(event);
+
+ SDMTraits traits = getTraitsForUpdate(event);
+ if (traits == null) {
+ return;
+ }
+
+ updateStateWithTraits(traits);
+
+ SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint = traits.thermostatTemperatureSetpoint;
+ if (thermostatTemperatureSetpoint != null) {
+ BigDecimal coolCelsius = thermostatTemperatureSetpoint.coolCelsius;
+ BigDecimal heatCelsius = thermostatTemperatureSetpoint.heatCelsius;
+ if (coolCelsius != null && heatCelsius != null) {
+ updateState(CHANNEL_MINIMUM_TEMPERATURE, temperatureToState(heatCelsius));
+ updateState(CHANNEL_MAXIMUM_TEMPERATURE, temperatureToState(coolCelsius));
+ }
+ }
+
+ SDMThermostatEcoTrait thermostatEco = traits.thermostatEco;
+ if (thermostatEco != null) {
+ if (thermostatEco.mode == SDMThermostatEcoMode.MANUAL_ECO) {
+ updateState(CHANNEL_MINIMUM_TEMPERATURE, temperatureToState(thermostatEco.heatCelsius));
+ updateState(CHANNEL_MAXIMUM_TEMPERATURE, temperatureToState(thermostatEco.coolCelsius));
+ }
+ }
+ }
+
+ private void setTargetTemperature(QuantityType<Temperature> value)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("setThermostatTargetTemperature value={}", value);
+ SDMThermostatModeTrait thermostatMode = device.traits.thermostatMode;
+ if (thermostatMode.mode == SDMThermostatMode.COOL) {
+ executeDeviceCommand(new SDMSetThermostatCoolSetpointRequest(toCelsiusBigDecimal(value)));
+ } else if (thermostatMode.mode == SDMThermostatMode.HEAT) {
+ executeDeviceCommand(new SDMSetThermostatHeatSetpointRequest(toCelsiusBigDecimal(value)));
+ } else {
+ throw new IllegalStateException("INVALID use case for setThermostatTargetTemperature");
+ }
+ }
+
+ private void setTargetTemperature(QuantityType<Temperature> minValue, QuantityType<Temperature> maxValue)
+ throws FailedSendingSDMDataException, InvalidSDMAccessTokenException {
+ logger.debug("setThermostatTargetTemperature minValue={} maxValue={}", minValue, maxValue);
+ SDMThermostatModeTrait thermostatMode = device.traits.thermostatMode;
+ if (thermostatMode.mode == SDMThermostatMode.HEATCOOL) {
+ executeDeviceCommand(new SDMSetThermostatRangeSetpointRequest(toCelsiusBigDecimal(minValue),
+ toCelsiusBigDecimal(maxValue)));
+ } else {
+ throw new IllegalStateException("INVALID use case for setThermostatTargetTemperature");
+ }
+ }
+
+ protected State temperatureToState(@Nullable BigDecimal value) {
+ if (value == null) {
+ return UnDefType.NULL;
+ }
+
+ QuantityType<Temperature> temperature = new QuantityType<>(value, CELSIUS);
+
+ if (getDeviceTemperatureUnit() == FAHRENHEIT) {
+ QuantityType<Temperature> converted = temperature.toUnit(FAHRENHEIT);
+ return converted == null ? UnDefType.NULL : converted;
+ }
+
+ return temperature;
+ }
+
+ private Unit<Temperature> getDeviceTemperatureUnit() {
+ SDMDeviceSettingsTrait deviceSettings = device.traits.deviceSettings;
+ if (deviceSettings == null) {
+ return CELSIUS;
+ }
+
+ switch (deviceSettings.temperatureScale) {
+ case CELSIUS:
+ return CELSIUS;
+ case FAHRENHEIT:
+ return FAHRENHEIT;
+ default:
+ return CELSIUS;
+ }
+ }
+
+ private BigDecimal toCelsiusBigDecimal(QuantityType<Temperature> temperature) {
+ QuantityType<Temperature> celsiusTemperature = temperature.toUnit(CELSIUS);
+ if (celsiusTemperature == null) {
+ throw new IllegalArgumentException(
+ String.format("Temperature '%s' cannot be converted to Celsius unit", temperature));
+ }
+ return celsiusTemperature.toBigDecimal();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.listener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nest.internal.sdm.api.PubSubAPI;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubMessage;
+
+/**
+ * Interface for listeners of {@link PubSubAPI} subscription events.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public interface PubSubSubscriptionListener {
+
+ void onError(Exception exception);
+
+ void onMessage(PubSubMessage message);
+
+ void onNoNewMessages();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.listener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nest.internal.sdm.api.SDMAPI;
+
+/**
+ * Interface for listeners that want to monitor if {@link SDMAPI} requests error or succeed.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public interface SDMAPIRequestListener {
+
+ void onError(Exception exception);
+
+ void onSuccess();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.listener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent;
+
+/**
+ * Interface for {@link SDMEvent} listeners.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public interface SDMEventListener {
+
+ void onEvent(SDMEvent event);
+}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.update;
-
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.function.Supplier;
-import java.util.stream.Collectors;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.data.NestIdentifiable;
-import org.openhab.binding.nest.internal.data.TopLevelData;
-import org.openhab.binding.nest.internal.listener.NestThingDataListener;
-
-/**
- * Handles all Nest data updates through delegation to the {@link NestUpdateHandler} for the respective data type.
- *
- * @author Wouter Born - Initial contribution
- */
-@NonNullByDefault
-public class NestCompositeUpdateHandler {
-
- private final Supplier<Set<String>> presentNestIdsSupplier;
- private final Map<Class<?>, NestUpdateHandler<?>> updateHandlersMap = new ConcurrentHashMap<>();
-
- public NestCompositeUpdateHandler(Supplier<Set<String>> presentNestIdsSupplier) {
- this.presentNestIdsSupplier = presentNestIdsSupplier;
- }
-
- public <T> boolean addListener(Class<T> dataClass, NestThingDataListener<T> listener) {
- return getOrCreateUpdateHandler(dataClass).addListener(listener);
- }
-
- public <T> boolean addListener(Class<T> dataClass, String nestId, NestThingDataListener<T> listener) {
- return getOrCreateUpdateHandler(dataClass).addListener(nestId, listener);
- }
-
- private Set<String> findMissingNestIds(Set<NestIdentifiable> updates) {
- Set<String> nestIds = updates.stream().map(u -> u.getId()).collect(Collectors.toSet());
- Set<String> missingNestIds = presentNestIdsSupplier.get();
- missingNestIds.removeAll(nestIds);
- return missingNestIds;
- }
-
- public @Nullable <T> T getLastUpdate(Class<T> dataClass, String nestId) {
- return getOrCreateUpdateHandler(dataClass).getLastUpdate(nestId);
- }
-
- public <T> List<T> getLastUpdates(Class<T> dataClass) {
- return getOrCreateUpdateHandler(dataClass).getLastUpdates();
- }
-
- private Set<NestIdentifiable> getNestUpdates(TopLevelData data) {
- Set<NestIdentifiable> updates = new HashSet<>();
- if (data.getDevices() != null) {
- if (data.getDevices().getCameras() != null) {
- updates.addAll(data.getDevices().getCameras().values());
- }
- if (data.getDevices().getSmokeCoAlarms() != null) {
- updates.addAll(data.getDevices().getSmokeCoAlarms().values());
- }
- if (data.getDevices().getThermostats() != null) {
- updates.addAll(data.getDevices().getThermostats().values());
- }
- }
- if (data.getStructures() != null) {
- updates.addAll(data.getStructures().values());
- }
- return updates;
- }
-
- @SuppressWarnings("unchecked")
- private <T> NestUpdateHandler<T> getOrCreateUpdateHandler(Class<T> dataClass) {
- NestUpdateHandler<T> handler = (NestUpdateHandler<T>) updateHandlersMap.get(dataClass);
- if (handler == null) {
- handler = new NestUpdateHandler<>();
- updateHandlersMap.put(dataClass, handler);
- }
- return handler;
- }
-
- @SuppressWarnings("unchecked")
- public void handleUpdate(TopLevelData data) {
- Set<NestIdentifiable> updates = getNestUpdates(data);
- updates.forEach(update -> {
- Class<NestIdentifiable> updateClass = (Class<NestIdentifiable>) update.getClass();
- getOrCreateUpdateHandler(updateClass).handleUpdate(updateClass, update.getId(), update);
- });
-
- Set<String> missingNestIds = findMissingNestIds(updates);
- if (!missingNestIds.isEmpty()) {
- updateHandlersMap.values().forEach(handler -> {
- handler.handleMissingNestIds(missingNestIds);
- });
- }
- }
-
- public <T> boolean removeListener(Class<T> dataClass, NestThingDataListener<T> listener) {
- return getOrCreateUpdateHandler(dataClass).removeListener(listener);
- }
-
- public <T> boolean removeListener(Class<T> dataClass, String nestId, NestThingDataListener<T> listener) {
- return getOrCreateUpdateHandler(dataClass).removeListener(nestId, listener);
- }
-
- public void resendLastUpdates() {
- updateHandlersMap.values().forEach(handler -> {
- handler.resendLastUpdates();
- });
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.update;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArraySet;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.listener.NestThingDataListener;
-
-/**
- * Handles the updates of one type of data by notifying listeners of changes and storing the update value.
- *
- * @author Wouter Born - Initial contribution
- *
- * @param <T> the type of update data
- */
-@NonNullByDefault
-public class NestUpdateHandler<T> {
-
- /**
- * The ID used for listeners that subscribe to any Nest update.
- */
- private static final String ANY_ID = "*";
-
- private final Map<String, T> lastUpdates = new ConcurrentHashMap<>();
- private final Map<String, Set<NestThingDataListener<T>>> listenersMap = new ConcurrentHashMap<>();
-
- public boolean addListener(NestThingDataListener<T> listener) {
- return addListener(ANY_ID, listener);
- }
-
- public boolean addListener(String nestId, NestThingDataListener<T> listener) {
- return getOrCreateListeners(nestId).add(listener);
- }
-
- public @Nullable T getLastUpdate(String nestId) {
- return lastUpdates.get(nestId);
- }
-
- public List<T> getLastUpdates() {
- return new ArrayList<>(lastUpdates.values());
- }
-
- private Set<NestThingDataListener<T>> getListeners(String nestId) {
- Set<NestThingDataListener<T>> listeners = new HashSet<>();
- Set<NestThingDataListener<T>> idListeners = listenersMap.get(nestId);
- if (idListeners != null) {
- listeners.addAll(idListeners);
- }
- Set<NestThingDataListener<T>> anyListeners = listenersMap.get(ANY_ID);
- if (anyListeners != null) {
- listeners.addAll(anyListeners);
- }
- return listeners;
- }
-
- private Set<NestThingDataListener<T>> getOrCreateListeners(String nestId) {
- Set<NestThingDataListener<T>> listeners = listenersMap.get(nestId);
- if (listeners == null) {
- listeners = new CopyOnWriteArraySet<>();
- listenersMap.put(nestId, listeners);
- }
- return listeners;
- }
-
- public void handleMissingNestIds(Set<String> nestIds) {
- nestIds.forEach(nestId -> {
- lastUpdates.remove(nestId);
- getListeners(nestId).forEach(l -> l.onMissingData(nestId));
- });
- }
-
- public void handleUpdate(Class<T> dataClass, String nestId, T update) {
- T lastUpdate = getLastUpdate(nestId);
- lastUpdates.put(nestId, update);
- notifyListeners(nestId, lastUpdate, update);
- }
-
- private void notifyListeners(String nestId, @Nullable T lastUpdate, T update) {
- Set<NestThingDataListener<T>> listeners = getListeners(nestId);
- if (lastUpdate == null) {
- listeners.forEach(l -> l.onNewData(update));
- } else if (!lastUpdate.equals(update)) {
- listeners.forEach(l -> l.onUpdatedData(lastUpdate, update));
- }
- }
-
- public boolean removeListener(NestThingDataListener<T> listener) {
- return removeListener(ANY_ID, listener);
- }
-
- public boolean removeListener(String nestId, NestThingDataListener<T> listener) {
- return getOrCreateListeners(nestId).remove(listener);
- }
-
- public void resendLastUpdates() {
- lastUpdates.forEach((nestId, update) -> notifyListeners(nestId, null, update));
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn;
+
+import java.time.Duration;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link WWNBindingConstants} class defines common constants which are used for the WWN implementation in the
+ * binding.
+ *
+ * @author David Bennett - Initial contribution
+ */
+@NonNullByDefault
+public class WWNBindingConstants {
+
+ public static final String BINDING_ID = "nest";
+
+ /** The URL to use to connect to Nest with. */
+ public static final String NEST_URL = "https://developer-api.nest.com";
+
+ /** The URL to get the access token when talking to Nest. */
+ public static final String NEST_ACCESS_TOKEN_URL = "https://api.home.nest.com/oauth2/access_token";
+
+ /** The path to set values on the thermostat when talking to Nest. */
+ public static final String NEST_THERMOSTAT_UPDATE_PATH = "/devices/thermostats/";
+
+ /** The path to set values on the structure when talking to Nest. */
+ public static final String NEST_STRUCTURE_UPDATE_PATH = "/structures/";
+
+ /** The path to set values on the camera when talking to Nest. */
+ public static final String NEST_CAMERA_UPDATE_PATH = "/devices/cameras/";
+
+ /** The path to set values on the camera when talking to Nest. */
+ public static final String NEST_SMOKE_ALARM_UPDATE_PATH = "/devices/smoke_co_alarms/";
+
+ /** The JSON content type used when talking to Nest. */
+ public static final String JSON_CONTENT_TYPE = "application/json";
+
+ /** To keep the streaming REST connection alive Nest sends every 30 seconds a message. */
+ public static final long KEEP_ALIVE_MILLIS = Duration.ofSeconds(30).toMillis();
+
+ /** To avoid API throttling errors (429 Too Many Requests) Nest recommends making at most one call per minute. */
+ public static final int MIN_SECONDS_BETWEEN_API_CALLS = 60;
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "wwn_account");
+ public static final ThingTypeUID THING_TYPE_CAMERA = new ThingTypeUID(BINDING_ID, "wwn_camera");
+ public static final ThingTypeUID THING_TYPE_SMOKE_DETECTOR = new ThingTypeUID(BINDING_ID, "wwn_smoke_detector");
+ public static final ThingTypeUID THING_TYPE_STRUCTURE = new ThingTypeUID(BINDING_ID, "wwn_structure");
+ public static final ThingTypeUID THING_TYPE_THERMOSTAT = new ThingTypeUID(BINDING_ID, "wwn_thermostat");
+
+ public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_CAMERA,
+ THING_TYPE_SMOKE_DETECTOR, THING_TYPE_STRUCTURE, THING_TYPE_THERMOSTAT);
+
+ // List of all channel group prefixes
+ public static final String CHANNEL_GROUP_CAMERA_PREFIX = "camera#";
+ public static final String CHANNEL_GROUP_LAST_EVENT_PREFIX = "last_event#";
+
+ // List of all Channel IDs
+ // read only channels (common)
+ public static final String CHANNEL_LAST_CONNECTION = "last_connection";
+
+ // read/write channels (thermostat)
+ public static final String CHANNEL_MODE = "mode";
+ public static final String CHANNEL_SET_POINT = "set_point";
+ public static final String CHANNEL_MAX_SET_POINT = "max_set_point";
+ public static final String CHANNEL_MIN_SET_POINT = "min_set_point";
+ public static final String CHANNEL_FAN_TIMER_ACTIVE = "fan_timer_active";
+ public static final String CHANNEL_FAN_TIMER_DURATION = "fan_timer_duration";
+
+ // read only channels (thermostat)
+ public static final String CHANNEL_ECO_MAX_SET_POINT = "eco_max_set_point";
+ public static final String CHANNEL_ECO_MIN_SET_POINT = "eco_min_set_point";
+ public static final String CHANNEL_LOCKED = "locked";
+ public static final String CHANNEL_LOCKED_MAX_SET_POINT = "locked_max_set_point";
+ public static final String CHANNEL_LOCKED_MIN_SET_POINT = "locked_min_set_point";
+ public static final String CHANNEL_TEMPERATURE = "temperature";
+ public static final String CHANNEL_HUMIDITY = "humidity";
+ public static final String CHANNEL_PREVIOUS_MODE = "previous_mode";
+ public static final String CHANNEL_STATE = "state";
+ public static final String CHANNEL_CAN_HEAT = "can_heat";
+ public static final String CHANNEL_CAN_COOL = "can_cool";
+ public static final String CHANNEL_FAN_TIMER_TIMEOUT = "fan_timer_timeout";
+ public static final String CHANNEL_HAS_FAN = "has_fan";
+ public static final String CHANNEL_HAS_LEAF = "has_leaf";
+ public static final String CHANNEL_SUNLIGHT_CORRECTION_ENABLED = "sunlight_correction_enabled";
+ public static final String CHANNEL_SUNLIGHT_CORRECTION_ACTIVE = "sunlight_correction_active";
+ public static final String CHANNEL_TIME_TO_TARGET = "time_to_target";
+ public static final String CHANNEL_USING_EMERGENCY_HEAT = "using_emergency_heat";
+
+ // read/write channels (camera)
+ public static final String CHANNEL_CAMERA_STREAMING = "camera#streaming";
+
+ // read only channels (camera)
+ public static final String CHANNEL_CAMERA_AUDIO_INPUT_ENABLED = "camera#audio_input_enabled";
+ public static final String CHANNEL_CAMERA_VIDEO_HISTORY_ENABLED = "camera#video_history_enabled";
+ public static final String CHANNEL_CAMERA_WEB_URL = "camera#web_url";
+ public static final String CHANNEL_CAMERA_APP_URL = "camera#app_url";
+ public static final String CHANNEL_CAMERA_PUBLIC_SHARE_ENABLED = "camera#public_share_enabled";
+ public static final String CHANNEL_CAMERA_PUBLIC_SHARE_URL = "camera#public_share_url";
+ public static final String CHANNEL_CAMERA_SNAPSHOT_URL = "camera#snapshot_url";
+ public static final String CHANNEL_CAMERA_LAST_ONLINE_CHANGE = "camera#last_online_change";
+
+ public static final String CHANNEL_LAST_EVENT_HAS_SOUND = "last_event#has_sound";
+ public static final String CHANNEL_LAST_EVENT_HAS_MOTION = "last_event#has_motion";
+ public static final String CHANNEL_LAST_EVENT_HAS_PERSON = "last_event#has_person";
+ public static final String CHANNEL_LAST_EVENT_START_TIME = "last_event#start_time";
+ public static final String CHANNEL_LAST_EVENT_END_TIME = "last_event#end_time";
+ public static final String CHANNEL_LAST_EVENT_URLS_EXPIRE_TIME = "last_event#urls_expire_time";
+ public static final String CHANNEL_LAST_EVENT_WEB_URL = "last_event#web_url";
+ public static final String CHANNEL_LAST_EVENT_APP_URL = "last_event#app_url";
+ public static final String CHANNEL_LAST_EVENT_IMAGE_URL = "last_event#image_url";
+ public static final String CHANNEL_LAST_EVENT_ANIMATED_IMAGE_URL = "last_event#animated_image_url";
+ public static final String CHANNEL_LAST_EVENT_ACTIVITY_ZONES = "last_event#activity_zones";
+
+ // read/write channels (smoke detector)
+
+ // read only channels (smoke detector)
+ public static final String CHANNEL_UI_COLOR_STATE = "ui_color_state";
+ public static final String CHANNEL_LOW_BATTERY = "low_battery";
+ public static final String CHANNEL_CO_ALARM_STATE = "co_alarm_state"; // Also in structure
+ public static final String CHANNEL_SMOKE_ALARM_STATE = "smoke_alarm_state"; // Also in structure
+ public static final String CHANNEL_MANUAL_TEST_ACTIVE = "manual_test_active";
+ public static final String CHANNEL_LAST_MANUAL_TEST_TIME = "last_manual_test_time";
+
+ // read/write channel (structure)
+ public static final String CHANNEL_AWAY = "away";
+
+ // read only channels (structure)
+ public static final String CHANNEL_COUNTRY_CODE = "country_code";
+ public static final String CHANNEL_POSTAL_CODE = "postal_code";
+ public static final String CHANNEL_PEAK_PERIOD_START_TIME = "peak_period_start_time";
+ public static final String CHANNEL_PEAK_PERIOD_END_TIME = "peak_period_end_time";
+ public static final String CHANNEL_TIME_ZONE = "time_zone";
+ public static final String CHANNEL_ETA_BEGIN = "eta_begin";
+ public static final String CHANNEL_RUSH_HOUR_REWARDS_ENROLLMENT = "rush_hour_rewards_enrollment";
+ public static final String CHANNEL_SECURITY_STATE = "security_state";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.handler.WWNAccountHandler;
+import org.openhab.binding.nest.internal.wwn.handler.WWNCameraHandler;
+import org.openhab.binding.nest.internal.wwn.handler.WWNSmokeDetectorHandler;
+import org.openhab.binding.nest.internal.wwn.handler.WWNStructureHandler;
+import org.openhab.binding.nest.internal.wwn.handler.WWNThermostatHandler;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+
+/**
+ * The {@link WWNThingHandlerFactory} is responsible for creating WWN thing handlers.
+ *
+ * @author David Bennett - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.nest")
+public class WWNThingHandlerFactory extends BaseThingHandlerFactory {
+
+ private final ClientBuilder clientBuilder;
+ private final SseEventSourceFactory eventSourceFactory;
+
+ @Activate
+ public WWNThingHandlerFactory(@Reference ClientBuilder clientBuilder,
+ @Reference SseEventSourceFactory eventSourceFactory) {
+ this.clientBuilder = clientBuilder;
+ this.eventSourceFactory = eventSourceFactory;
+ }
+
+ /**
+ * The things this factory supports creating.
+ */
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ /**
+ * Creates a handler for the specific thing. This also creates the discovery service when the bridge is created.
+ */
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
+ return new WWNAccountHandler((Bridge) thing, clientBuilder, eventSourceFactory);
+ } else if (THING_TYPE_CAMERA.equals(thingTypeUID)) {
+ return new WWNCameraHandler(thing);
+ } else if (THING_TYPE_SMOKE_DETECTOR.equals(thingTypeUID)) {
+ return new WWNSmokeDetectorHandler(thing);
+ } else if (THING_TYPE_STRUCTURE.equals(thingTypeUID)) {
+ return new WWNStructureHandler(thing);
+ } else if (THING_TYPE_THERMOSTAT.equals(thingTypeUID)) {
+ return new WWNThermostatHandler(thing);
+ }
+
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn;
+
+import java.io.Reader;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * Utility class for sharing WWN utility methods between objects.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public final class WWNUtils {
+
+ private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+
+ private WWNUtils() {
+ // hidden utility class constructor
+ }
+
+ public static <T> T fromJson(String json, Class<T> dataClass) {
+ return GSON.fromJson(json, dataClass);
+ }
+
+ public static <T> T fromJson(Reader reader, Class<T> dataClass) {
+ return GSON.fromJson(reader, dataClass);
+ }
+
+ public static String toJson(Object object) {
+ return GSON.toJson(object);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The configuration for the WWN account, allowing it to talk to Nest.
+ *
+ * @author David Bennett - Initial contribution
+ */
+@NonNullByDefault
+public class WWNAccountConfiguration {
+ public static final String PRODUCT_ID = "productId";
+ /** Product ID from the Nest product page. */
+ public String productId = "";
+
+ public static final String PRODUCT_SECRET = "productSecret";
+ /** Product secret from the Nest product page. */
+ public String productSecret = "";
+
+ public static final String PINCODE = "pincode";
+ /** Product pincode from the Nest authorization page. */
+ public @Nullable String pincode;
+
+ public static final String ACCESS_TOKEN = "accessToken";
+ /** The access token to use once retrieved from Nest. */
+ public @Nullable String accessToken;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The configuration for WWN devices.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Add device configuration to allow file based configuration
+ */
+@NonNullByDefault
+public class WWNDeviceConfiguration {
+ public static final String DEVICE_ID = "deviceId";
+ /** Device ID which can be retrieved with the Nest API. */
+ public String deviceId = "";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The configuration for WWN structures.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Add device configuration to allow file based configuration
+ */
+@NonNullByDefault
+public class WWNStructureConfiguration {
+ public static final String STRUCTURE_ID = "structureId";
+ /** Structure ID which can be retrieved with the Nest API. */
+ public String structureId = "";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.discovery;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiConsumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.config.WWNDeviceConfiguration;
+import org.openhab.binding.nest.internal.wwn.config.WWNStructureConfiguration;
+import org.openhab.binding.nest.internal.wwn.dto.BaseWWNDevice;
+import org.openhab.binding.nest.internal.wwn.dto.WWNCamera;
+import org.openhab.binding.nest.internal.wwn.dto.WWNSmokeDetector;
+import org.openhab.binding.nest.internal.wwn.dto.WWNStructure;
+import org.openhab.binding.nest.internal.wwn.dto.WWNThermostat;
+import org.openhab.binding.nest.internal.wwn.handler.WWNAccountHandler;
+import org.openhab.binding.nest.internal.wwn.listener.WWNThingDataListener;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This service connects to the Nest account and creates the correct discovery results for devices
+ * as they are found through the WWN API.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add representation properties
+ */
+@NonNullByDefault
+public class WWNDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
+
+ private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_CAMERA, THING_TYPE_THERMOSTAT,
+ THING_TYPE_SMOKE_DETECTOR, THING_TYPE_STRUCTURE);
+
+ private final Logger logger = LoggerFactory.getLogger(WWNDiscoveryService.class);
+
+ private final DiscoveryDataListener<WWNCamera> cameraDiscoveryDataListener = new DiscoveryDataListener<>(
+ WWNCamera.class, THING_TYPE_CAMERA, this::addDeviceDiscoveryResult);
+ private final DiscoveryDataListener<WWNSmokeDetector> smokeDetectorDiscoveryDataListener = new DiscoveryDataListener<>(
+ WWNSmokeDetector.class, THING_TYPE_SMOKE_DETECTOR, this::addDeviceDiscoveryResult);
+ private final DiscoveryDataListener<WWNStructure> structureDiscoveryDataListener = new DiscoveryDataListener<>(
+ WWNStructure.class, THING_TYPE_STRUCTURE, this::addStructureDiscoveryResult);
+ private final DiscoveryDataListener<WWNThermostat> thermostatDiscoveryDataListener = new DiscoveryDataListener<>(
+ WWNThermostat.class, THING_TYPE_THERMOSTAT, this::addDeviceDiscoveryResult);
+
+ @SuppressWarnings("rawtypes")
+ private final List<DiscoveryDataListener> discoveryDataListeners = List.of(cameraDiscoveryDataListener,
+ smokeDetectorDiscoveryDataListener, structureDiscoveryDataListener, thermostatDiscoveryDataListener);
+
+ private @NonNullByDefault({}) WWNAccountHandler accountHandler;
+
+ private static class DiscoveryDataListener<T> implements WWNThingDataListener<T> {
+ private Class<T> dataClass;
+ private ThingTypeUID thingTypeUID;
+ private BiConsumer<T, ThingTypeUID> onDiscovered;
+
+ private DiscoveryDataListener(Class<T> dataClass, ThingTypeUID thingTypeUID,
+ BiConsumer<T, ThingTypeUID> onDiscovered) {
+ this.dataClass = dataClass;
+ this.thingTypeUID = thingTypeUID;
+ this.onDiscovered = onDiscovered;
+ }
+
+ @Override
+ public void onNewData(T data) {
+ onDiscovered.accept(data, thingTypeUID);
+ }
+
+ @Override
+ public void onUpdatedData(T oldData, T data) {
+ }
+
+ @Override
+ public void onMissingData(String nestId) {
+ }
+ }
+
+ public WWNDiscoveryService() {
+ super(SUPPORTED_THING_TYPES, 60, true);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void activate() {
+ discoveryDataListeners.forEach(listener -> accountHandler.addThingDataListener(listener.dataClass, listener));
+ addDiscoveryResultsFromLastUpdates();
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void deactivate() {
+ discoveryDataListeners
+ .forEach(listener -> accountHandler.removeThingDataListener(listener.dataClass, listener));
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return accountHandler;
+ }
+
+ @Override
+ public void setThingHandler(ThingHandler handler) {
+ if (handler instanceof WWNAccountHandler) {
+ accountHandler = (WWNAccountHandler) handler;
+ }
+ }
+
+ @Override
+ protected void startScan() {
+ addDiscoveryResultsFromLastUpdates();
+ }
+
+ @SuppressWarnings("unchecked")
+ private void addDiscoveryResultsFromLastUpdates() {
+ discoveryDataListeners.forEach(listener -> addDiscoveryResultsFromLastUpdates(listener.dataClass,
+ listener.thingTypeUID, listener.onDiscovered));
+ }
+
+ private <T> void addDiscoveryResultsFromLastUpdates(Class<T> dataClass, ThingTypeUID thingTypeUID,
+ BiConsumer<T, ThingTypeUID> onDiscovered) {
+ List<T> lastUpdates = accountHandler.getLastUpdates(dataClass);
+ lastUpdates.forEach(lastUpdate -> onDiscovered.accept(lastUpdate, thingTypeUID));
+ }
+
+ private void addDeviceDiscoveryResult(BaseWWNDevice device, ThingTypeUID typeUID) {
+ ThingUID bridgeUID = accountHandler.getThing().getUID();
+ ThingUID thingUID = new ThingUID(typeUID, bridgeUID, device.getDeviceId());
+ logger.debug("Discovered {}", thingUID);
+ Map<String, Object> properties = Map.of(WWNDeviceConfiguration.DEVICE_ID, device.getDeviceId(),
+ PROPERTY_FIRMWARE_VERSION, device.getSoftwareVersion());
+ thingDiscovered(DiscoveryResultBuilder.create(thingUID) //
+ .withThingType(typeUID) //
+ .withLabel(device.getNameLong()) //
+ .withBridge(bridgeUID) //
+ .withProperties(properties) //
+ .withRepresentationProperty(WWNDeviceConfiguration.DEVICE_ID) //
+ .build() //
+ );
+ }
+
+ public void addStructureDiscoveryResult(WWNStructure structure, ThingTypeUID typeUID) {
+ ThingUID bridgeUID = accountHandler.getThing().getUID();
+ ThingUID thingUID = new ThingUID(typeUID, bridgeUID, structure.getStructureId());
+ logger.debug("Discovered {}", thingUID);
+ Map<String, Object> properties = Map.of(WWNStructureConfiguration.STRUCTURE_ID, structure.getStructureId());
+ thingDiscovered(DiscoveryResultBuilder.create(thingUID) //
+ .withThingType(THING_TYPE_STRUCTURE) //
+ .withLabel(structure.getName()) //
+ .withBridge(bridgeUID) //
+ .withProperties(properties) //
+ .withRepresentationProperty(WWNStructureConfiguration.STRUCTURE_ID) //
+ .build() //
+ );
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Date;
+
+/**
+ * Default properties shared across all WWN devices.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class BaseWWNDevice implements WWNIdentifiable {
+
+ private String deviceId;
+ private String name;
+ private String nameLong;
+ private Date lastConnection;
+ private Boolean isOnline;
+ private String softwareVersion;
+ private String structureId;
+
+ private String whereId;
+
+ @Override
+ public String getId() {
+ return deviceId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ public Date getLastConnection() {
+ return lastConnection;
+ }
+
+ public Boolean isOnline() {
+ return isOnline;
+ }
+
+ public String getNameLong() {
+ return nameLong;
+ }
+
+ public String getSoftwareVersion() {
+ return softwareVersion;
+ }
+
+ public String getStructureId() {
+ return structureId;
+ }
+
+ public String getWhereId() {
+ return whereId;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ BaseWWNDevice other = (BaseWWNDevice) obj;
+ if (deviceId == null) {
+ if (other.deviceId != null) {
+ return false;
+ }
+ } else if (!deviceId.equals(other.deviceId)) {
+ return false;
+ }
+ if (isOnline == null) {
+ if (other.isOnline != null) {
+ return false;
+ }
+ } else if (!isOnline.equals(other.isOnline)) {
+ return false;
+ }
+ if (lastConnection == null) {
+ if (other.lastConnection != null) {
+ return false;
+ }
+ } else if (!lastConnection.equals(other.lastConnection)) {
+ return false;
+ }
+ if (name == null) {
+ if (other.name != null) {
+ return false;
+ }
+ } else if (!name.equals(other.name)) {
+ return false;
+ }
+ if (nameLong == null) {
+ if (other.nameLong != null) {
+ return false;
+ }
+ } else if (!nameLong.equals(other.nameLong)) {
+ return false;
+ }
+ if (softwareVersion == null) {
+ if (other.softwareVersion != null) {
+ return false;
+ }
+ } else if (!softwareVersion.equals(other.softwareVersion)) {
+ return false;
+ }
+ if (structureId == null) {
+ if (other.structureId != null) {
+ return false;
+ }
+ } else if (!structureId.equals(other.structureId)) {
+ return false;
+ }
+ if (whereId == null) {
+ if (other.whereId != null) {
+ return false;
+ }
+ } else if (!whereId.equals(other.whereId)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((deviceId == null) ? 0 : deviceId.hashCode());
+ result = prime * result + ((isOnline == null) ? 0 : isOnline.hashCode());
+ result = prime * result + ((lastConnection == null) ? 0 : lastConnection.hashCode());
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ result = prime * result + ((nameLong == null) ? 0 : nameLong.hashCode());
+ result = prime * result + ((softwareVersion == null) ? 0 : softwareVersion.hashCode());
+ result = prime * result + ((structureId == null) ? 0 : structureId.hashCode());
+ result = prime * result + ((whereId == null) ? 0 : whereId.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("BaseNestDevice [deviceId=").append(deviceId).append(", name=").append(name)
+ .append(", nameLong=").append(nameLong).append(", lastConnection=").append(lastConnection)
+ .append(", isOnline=").append(isOnline).append(", softwareVersion=").append(softwareVersion)
+ .append(", structureId=").append(structureId).append(", whereId=").append(whereId).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * Deals with the access token data that comes back from WWN when it is requested.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNAccessTokenData {
+
+ private String accessToken;
+ private Long expiresIn;
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public Long getExpiresIn() {
+ return expiresIn;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNAccessTokenData other = (WWNAccessTokenData) obj;
+ if (accessToken == null) {
+ if (other.accessToken != null) {
+ return false;
+ }
+ } else if (!accessToken.equals(other.accessToken)) {
+ return false;
+ }
+ if (expiresIn == null) {
+ if (other.expiresIn != null) {
+ return false;
+ }
+ } else if (!expiresIn.equals(other.expiresIn)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((accessToken == null) ? 0 : accessToken.hashCode());
+ result = prime * result + ((expiresIn == null) ? 0 : expiresIn.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("AccessTokenData [accessToken=").append(accessToken).append(", expiresIn=").append(expiresIn)
+ .append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * The data for a WWN camera activity zone.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Extract ActivityZone object from Camera
+ */
+public class WWNActivityZone {
+
+ private String name;
+ private int id;
+
+ public String getName() {
+ return name;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNActivityZone other = (WWNActivityZone) obj;
+ if (id != other.id) {
+ return false;
+ }
+ if (name == null) {
+ if (other.name != null) {
+ return false;
+ }
+ } else if (!name.equals(other.name)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + id;
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("CameraActivityZone [name=").append(name).append(", id=").append(id).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * The data for the WWN camera.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNCamera extends BaseWWNDevice {
+
+ private Boolean isStreaming;
+ private Boolean isAudioInputEnabled;
+ private Date lastIsOnlineChange;
+ private Boolean isVideoHistoryEnabled;
+ private String webUrl;
+ private String appUrl;
+ private Boolean isPublicShareEnabled;
+ private List<WWNActivityZone> activityZones;
+ private String publicShareUrl;
+ private String snapshotUrl;
+ private WWNCameraEvent lastEvent;
+
+ public Boolean isStreaming() {
+ return isStreaming;
+ }
+
+ public Boolean isAudioInputEnabled() {
+ return isAudioInputEnabled;
+ }
+
+ public Date getLastIsOnlineChange() {
+ return lastIsOnlineChange;
+ }
+
+ public Boolean isVideoHistoryEnabled() {
+ return isVideoHistoryEnabled;
+ }
+
+ public String getWebUrl() {
+ return webUrl;
+ }
+
+ public String getAppUrl() {
+ return appUrl;
+ }
+
+ public Boolean isPublicShareEnabled() {
+ return isPublicShareEnabled;
+ }
+
+ public List<WWNActivityZone> getActivityZones() {
+ return activityZones;
+ }
+
+ public String getPublicShareUrl() {
+ return publicShareUrl;
+ }
+
+ public String getSnapshotUrl() {
+ return snapshotUrl;
+ }
+
+ public WWNCameraEvent getLastEvent() {
+ return lastEvent;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || !super.equals(obj)) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNCamera other = (WWNCamera) obj;
+ if (activityZones == null) {
+ if (other.activityZones != null) {
+ return false;
+ }
+ } else if (!activityZones.equals(other.activityZones)) {
+ return false;
+ }
+ if (appUrl == null) {
+ if (other.appUrl != null) {
+ return false;
+ }
+ } else if (!appUrl.equals(other.appUrl)) {
+ return false;
+ }
+ if (isAudioInputEnabled == null) {
+ if (other.isAudioInputEnabled != null) {
+ return false;
+ }
+ } else if (!isAudioInputEnabled.equals(other.isAudioInputEnabled)) {
+ return false;
+ }
+ if (isPublicShareEnabled == null) {
+ if (other.isPublicShareEnabled != null) {
+ return false;
+ }
+ } else if (!isPublicShareEnabled.equals(other.isPublicShareEnabled)) {
+ return false;
+ }
+ if (isStreaming == null) {
+ if (other.isStreaming != null) {
+ return false;
+ }
+ } else if (!isStreaming.equals(other.isStreaming)) {
+ return false;
+ }
+ if (isVideoHistoryEnabled == null) {
+ if (other.isVideoHistoryEnabled != null) {
+ return false;
+ }
+ } else if (!isVideoHistoryEnabled.equals(other.isVideoHistoryEnabled)) {
+ return false;
+ }
+ if (lastEvent == null) {
+ if (other.lastEvent != null) {
+ return false;
+ }
+ } else if (!lastEvent.equals(other.lastEvent)) {
+ return false;
+ }
+ if (lastIsOnlineChange == null) {
+ if (other.lastIsOnlineChange != null) {
+ return false;
+ }
+ } else if (!lastIsOnlineChange.equals(other.lastIsOnlineChange)) {
+ return false;
+ }
+ if (publicShareUrl == null) {
+ if (other.publicShareUrl != null) {
+ return false;
+ }
+ } else if (!publicShareUrl.equals(other.publicShareUrl)) {
+ return false;
+ }
+ if (snapshotUrl == null) {
+ if (other.snapshotUrl != null) {
+ return false;
+ }
+ } else if (!snapshotUrl.equals(other.snapshotUrl)) {
+ return false;
+ }
+ if (webUrl == null) {
+ if (other.webUrl != null) {
+ return false;
+ }
+ } else if (!webUrl.equals(other.webUrl)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = super.hashCode();
+ result = prime * result + ((activityZones == null) ? 0 : activityZones.hashCode());
+ result = prime * result + ((appUrl == null) ? 0 : appUrl.hashCode());
+ result = prime * result + ((isAudioInputEnabled == null) ? 0 : isAudioInputEnabled.hashCode());
+ result = prime * result + ((isPublicShareEnabled == null) ? 0 : isPublicShareEnabled.hashCode());
+ result = prime * result + ((isStreaming == null) ? 0 : isStreaming.hashCode());
+ result = prime * result + ((isVideoHistoryEnabled == null) ? 0 : isVideoHistoryEnabled.hashCode());
+ result = prime * result + ((lastEvent == null) ? 0 : lastEvent.hashCode());
+ result = prime * result + ((lastIsOnlineChange == null) ? 0 : lastIsOnlineChange.hashCode());
+ result = prime * result + ((publicShareUrl == null) ? 0 : publicShareUrl.hashCode());
+ result = prime * result + ((snapshotUrl == null) ? 0 : snapshotUrl.hashCode());
+ result = prime * result + ((webUrl == null) ? 0 : webUrl.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Camera [isStreaming=").append(isStreaming).append(", isAudioInputEnabled=")
+ .append(isAudioInputEnabled).append(", lastIsOnlineChange=").append(lastIsOnlineChange)
+ .append(", isVideoHistoryEnabled=").append(isVideoHistoryEnabled).append(", webUrl=").append(webUrl)
+ .append(", appUrl=").append(appUrl).append(", isPublicShareEnabled=").append(isPublicShareEnabled)
+ .append(", activityZones=").append(activityZones).append(", publicShareUrl=").append(publicShareUrl)
+ .append(", snapshotUrl=").append(snapshotUrl).append(", lastEvent=").append(lastEvent)
+ .append(", getId()=").append(getId()).append(", getName()=").append(getName())
+ .append(", getDeviceId()=").append(getDeviceId()).append(", getLastConnection()=")
+ .append(getLastConnection()).append(", isOnline()=").append(isOnline()).append(", getNameLong()=")
+ .append(getNameLong()).append(", getSoftwareVersion()=").append(getSoftwareVersion())
+ .append(", getStructureId()=").append(getStructureId()).append(", getWhereId()=").append(getWhereId())
+ .append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * The data for a WWN camera event.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Extract CameraEvent object from Camera
+ * @author Wouter Born - Add equals, hashCode, toString methods
+ */
+public class WWNCameraEvent {
+
+ private Boolean hasSound;
+ private Boolean hasMotion;
+ private Boolean hasPerson;
+ private Date startTime;
+ private Date endTime;
+ private Date urlsExpireTime;
+ private String webUrl;
+ private String appUrl;
+ private String imageUrl;
+ private String animatedImageUrl;
+ private List<String> activityZoneIds;
+
+ public Boolean isHasSound() {
+ return hasSound;
+ }
+
+ public Boolean isHasMotion() {
+ return hasMotion;
+ }
+
+ public Boolean isHasPerson() {
+ return hasPerson;
+ }
+
+ public Date getStartTime() {
+ return startTime;
+ }
+
+ public Date getEndTime() {
+ return endTime;
+ }
+
+ public Date getUrlsExpireTime() {
+ return urlsExpireTime;
+ }
+
+ public String getWebUrl() {
+ return webUrl;
+ }
+
+ public String getAppUrl() {
+ return appUrl;
+ }
+
+ public String getImageUrl() {
+ return imageUrl;
+ }
+
+ public String getAnimatedImageUrl() {
+ return animatedImageUrl;
+ }
+
+ public List<String> getActivityZones() {
+ return activityZoneIds;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNCameraEvent other = (WWNCameraEvent) obj;
+ if (activityZoneIds == null) {
+ if (other.activityZoneIds != null) {
+ return false;
+ }
+ } else if (!activityZoneIds.equals(other.activityZoneIds)) {
+ return false;
+ }
+ if (animatedImageUrl == null) {
+ if (other.animatedImageUrl != null) {
+ return false;
+ }
+ } else if (!animatedImageUrl.equals(other.animatedImageUrl)) {
+ return false;
+ }
+ if (appUrl == null) {
+ if (other.appUrl != null) {
+ return false;
+ }
+ } else if (!appUrl.equals(other.appUrl)) {
+ return false;
+ }
+ if (endTime == null) {
+ if (other.endTime != null) {
+ return false;
+ }
+ } else if (!endTime.equals(other.endTime)) {
+ return false;
+ }
+ if (hasMotion == null) {
+ if (other.hasMotion != null) {
+ return false;
+ }
+ } else if (!hasMotion.equals(other.hasMotion)) {
+ return false;
+ }
+ if (hasPerson == null) {
+ if (other.hasPerson != null) {
+ return false;
+ }
+ } else if (!hasPerson.equals(other.hasPerson)) {
+ return false;
+ }
+ if (hasSound == null) {
+ if (other.hasSound != null) {
+ return false;
+ }
+ } else if (!hasSound.equals(other.hasSound)) {
+ return false;
+ }
+ if (imageUrl == null) {
+ if (other.imageUrl != null) {
+ return false;
+ }
+ } else if (!imageUrl.equals(other.imageUrl)) {
+ return false;
+ }
+ if (startTime == null) {
+ if (other.startTime != null) {
+ return false;
+ }
+ } else if (!startTime.equals(other.startTime)) {
+ return false;
+ }
+ if (urlsExpireTime == null) {
+ if (other.urlsExpireTime != null) {
+ return false;
+ }
+ } else if (!urlsExpireTime.equals(other.urlsExpireTime)) {
+ return false;
+ }
+ if (webUrl == null) {
+ if (other.webUrl != null) {
+ return false;
+ }
+ } else if (!webUrl.equals(other.webUrl)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((activityZoneIds == null) ? 0 : activityZoneIds.hashCode());
+ result = prime * result + ((animatedImageUrl == null) ? 0 : animatedImageUrl.hashCode());
+ result = prime * result + ((appUrl == null) ? 0 : appUrl.hashCode());
+ result = prime * result + ((endTime == null) ? 0 : endTime.hashCode());
+ result = prime * result + ((hasMotion == null) ? 0 : hasMotion.hashCode());
+ result = prime * result + ((hasPerson == null) ? 0 : hasPerson.hashCode());
+ result = prime * result + ((hasSound == null) ? 0 : hasSound.hashCode());
+ result = prime * result + ((imageUrl == null) ? 0 : imageUrl.hashCode());
+ result = prime * result + ((startTime == null) ? 0 : startTime.hashCode());
+ result = prime * result + ((urlsExpireTime == null) ? 0 : urlsExpireTime.hashCode());
+ result = prime * result + ((webUrl == null) ? 0 : webUrl.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Event [hasSound=").append(hasSound).append(", hasMotion=").append(hasMotion)
+ .append(", hasPerson=").append(hasPerson).append(", startTime=").append(startTime).append(", endTime=")
+ .append(endTime).append(", urlsExpireTime=").append(urlsExpireTime).append(", webUrl=").append(webUrl)
+ .append(", appUrl=").append(appUrl).append(", imageUrl=").append(imageUrl).append(", animatedImageUrl=")
+ .append(animatedImageUrl).append(", activityZoneIds=").append(activityZoneIds).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Map;
+
+/**
+ * All the WWN devices broken up by type.
+ *
+ * @author David Bennett - Initial contribution
+ */
+public class WWNDevices {
+
+ private Map<String, WWNThermostat> thermostats;
+ private Map<String, WWNSmokeDetector> smokeCoAlarms;
+ private Map<String, WWNCamera> cameras;
+
+ /** Id to thermostat mapping */
+ public Map<String, WWNThermostat> getThermostats() {
+ return thermostats;
+ }
+
+ /** Id to camera mapping */
+ public Map<String, WWNCamera> getCameras() {
+ return cameras;
+ }
+
+ /** Id to smoke detector */
+ public Map<String, WWNSmokeDetector> getSmokeCoAlarms() {
+ return smokeCoAlarms;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNDevices other = (WWNDevices) obj;
+ if (cameras == null) {
+ if (other.cameras != null) {
+ return false;
+ }
+ } else if (!cameras.equals(other.cameras)) {
+ return false;
+ }
+ if (smokeCoAlarms == null) {
+ if (other.smokeCoAlarms != null) {
+ return false;
+ }
+ } else if (!smokeCoAlarms.equals(other.smokeCoAlarms)) {
+ return false;
+ }
+ if (thermostats == null) {
+ if (other.thermostats != null) {
+ return false;
+ }
+ } else if (!thermostats.equals(other.thermostats)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((cameras == null) ? 0 : cameras.hashCode());
+ result = prime * result + ((smokeCoAlarms == null) ? 0 : smokeCoAlarms.hashCode());
+ result = prime * result + ((thermostats == null) ? 0 : thermostats.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("NestDevices [thermostats=").append(thermostats).append(", smokeCoAlarms=").append(smokeCoAlarms)
+ .append(", cameras=").append(cameras).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Date;
+
+/**
+ * Used to set and update the WWN ETA values.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Extract ETA object from Structure
+ * @author Wouter Born - Add equals, hashCode, toString methods
+ */
+public class WWNETA {
+
+ private String tripId;
+ private Date estimatedArrivalWindowBegin;
+ private Date estimatedArrivalWindowEnd;
+
+ public String getTripId() {
+ return tripId;
+ }
+
+ public void setTripId(String tripId) {
+ this.tripId = tripId;
+ }
+
+ public Date getEstimatedArrivalWindowBegin() {
+ return estimatedArrivalWindowBegin;
+ }
+
+ public void setEstimatedArrivalWindowBegin(Date estimatedArrivalWindowBegin) {
+ this.estimatedArrivalWindowBegin = estimatedArrivalWindowBegin;
+ }
+
+ public Date getEstimatedArrivalWindowEnd() {
+ return estimatedArrivalWindowEnd;
+ }
+
+ public void setEstimatedArrivalWindowEnd(Date estimatedArrivalWindowEnd) {
+ this.estimatedArrivalWindowEnd = estimatedArrivalWindowEnd;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNETA other = (WWNETA) obj;
+ if (estimatedArrivalWindowBegin == null) {
+ if (other.estimatedArrivalWindowBegin != null) {
+ return false;
+ }
+ } else if (!estimatedArrivalWindowBegin.equals(other.estimatedArrivalWindowBegin)) {
+ return false;
+ }
+ if (estimatedArrivalWindowEnd == null) {
+ if (other.estimatedArrivalWindowEnd != null) {
+ return false;
+ }
+ } else if (!estimatedArrivalWindowEnd.equals(other.estimatedArrivalWindowEnd)) {
+ return false;
+ }
+ if (tripId == null) {
+ if (other.tripId != null) {
+ return false;
+ }
+ } else if (!tripId.equals(other.tripId)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((estimatedArrivalWindowBegin == null) ? 0 : estimatedArrivalWindowBegin.hashCode());
+ result = prime * result + ((estimatedArrivalWindowEnd == null) ? 0 : estimatedArrivalWindowEnd.hashCode());
+ result = prime * result + ((tripId == null) ? 0 : tripId.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("ETA [tripId=").append(tripId).append(", estimatedArrivalWindowBegin=")
+ .append(estimatedArrivalWindowBegin).append(", estimatedArrivalWindowEnd=")
+ .append(estimatedArrivalWindowEnd).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * The data of WWN API errors.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Improve exception handling
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNErrorData {
+
+ private String error;
+ private String type;
+ private String message;
+ private String instance;
+
+ public String getError() {
+ return error;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public String getInstance() {
+ return instance;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNErrorData other = (WWNErrorData) obj;
+ if (error == null) {
+ if (other.error != null) {
+ return false;
+ }
+ } else if (!error.equals(other.error)) {
+ return false;
+ }
+ if (instance == null) {
+ if (other.instance != null) {
+ return false;
+ }
+ } else if (!instance.equals(other.instance)) {
+ return false;
+ }
+ if (message == null) {
+ if (other.message != null) {
+ return false;
+ }
+ } else if (!message.equals(other.message)) {
+ return false;
+ }
+ if (type == null) {
+ if (other.type != null) {
+ return false;
+ }
+ } else if (!type.equals(other.type)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((error == null) ? 0 : error.hashCode());
+ result = prime * result + ((instance == null) ? 0 : instance.hashCode());
+ result = prime * result + ((message == null) ? 0 : message.hashCode());
+ result = prime * result + ((type == null) ? 0 : type.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("ErrorData [error=").append(error).append(", type=").append(type).append(", message=")
+ .append(message).append(", instance=").append(instance).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * Interface for uniquely identifiable WWN objects (device or a structure).
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Simplify working with deviceId and structureId
+ */
+public interface WWNIdentifiable {
+
+ /**
+ * Returns the identifier that uniquely identifies the WWN object (deviceId or structureId).
+ */
+ String getId();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * The WWN meta data in the data downloads.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNMetadata {
+
+ private String accessToken;
+ private String clientVersion;
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public String getClientVersion() {
+ return clientVersion;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNMetadata other = (WWNMetadata) obj;
+ if (accessToken == null) {
+ if (other.accessToken != null) {
+ return false;
+ }
+ } else if (!accessToken.equals(other.accessToken)) {
+ return false;
+ }
+ if (clientVersion == null) {
+ if (other.clientVersion != null) {
+ return false;
+ }
+ } else if (!clientVersion.equals(other.clientVersion)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((accessToken == null) ? 0 : accessToken.hashCode());
+ result = prime * result + ((clientVersion == null) ? 0 : clientVersion.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("NestMetadata [accessToken=").append(accessToken).append(", clientVersion=")
+ .append(clientVersion).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Date;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Data for the WWN smoke detector.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNSmokeDetector extends BaseWWNDevice {
+
+ private BatteryHealth batteryHealth;
+ private AlarmState coAlarmState;
+ private Date lastManualTestTime;
+ private AlarmState smokeAlarmState;
+ private Boolean isManualTestActive;
+ private UiColorState uiColorState;
+
+ public UiColorState getUiColorState() {
+ return uiColorState;
+ }
+
+ public BatteryHealth getBatteryHealth() {
+ return batteryHealth;
+ }
+
+ public AlarmState getCoAlarmState() {
+ return coAlarmState;
+ }
+
+ public Date getLastManualTestTime() {
+ return lastManualTestTime;
+ }
+
+ public AlarmState getSmokeAlarmState() {
+ return smokeAlarmState;
+ }
+
+ public Boolean isManualTestActive() {
+ return isManualTestActive;
+ }
+
+ public enum BatteryHealth {
+ @SerializedName("ok")
+ OK,
+ @SerializedName("replace")
+ REPLACE
+ }
+
+ public enum AlarmState {
+ @SerializedName("ok")
+ OK,
+ @SerializedName("emergency")
+ EMERGENCY,
+ @SerializedName("warning")
+ WARNING
+ }
+
+ public enum UiColorState {
+ @SerializedName("gray")
+ GRAY,
+ @SerializedName("green")
+ GREEN,
+ @SerializedName("yellow")
+ YELLOW,
+ @SerializedName("red")
+ RED
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || !super.equals(obj)) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNSmokeDetector other = (WWNSmokeDetector) obj;
+ if (batteryHealth != other.batteryHealth) {
+ return false;
+ }
+ if (coAlarmState != other.coAlarmState) {
+ return false;
+ }
+ if (isManualTestActive == null) {
+ if (other.isManualTestActive != null) {
+ return false;
+ }
+ } else if (!isManualTestActive.equals(other.isManualTestActive)) {
+ return false;
+ }
+ if (lastManualTestTime == null) {
+ if (other.lastManualTestTime != null) {
+ return false;
+ }
+ } else if (!lastManualTestTime.equals(other.lastManualTestTime)) {
+ return false;
+ }
+ if (smokeAlarmState != other.smokeAlarmState) {
+ return false;
+ }
+ return uiColorState == other.uiColorState;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = super.hashCode();
+ result = prime * result + ((batteryHealth == null) ? 0 : batteryHealth.hashCode());
+ result = prime * result + ((coAlarmState == null) ? 0 : coAlarmState.hashCode());
+ result = prime * result + ((isManualTestActive == null) ? 0 : isManualTestActive.hashCode());
+ result = prime * result + ((lastManualTestTime == null) ? 0 : lastManualTestTime.hashCode());
+ result = prime * result + ((smokeAlarmState == null) ? 0 : smokeAlarmState.hashCode());
+ result = prime * result + ((uiColorState == null) ? 0 : uiColorState.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("SmokeDetector [batteryHealth=").append(batteryHealth).append(", coAlarmState=")
+ .append(coAlarmState).append(", lastManualTestTime=").append(lastManualTestTime)
+ .append(", smokeAlarmState=").append(smokeAlarmState).append(", isManualTestActive=")
+ .append(isManualTestActive).append(", uiColorState=").append(uiColorState).append(", getId()=")
+ .append(getId()).append(", getName()=").append(getName()).append(", getDeviceId()=")
+ .append(getDeviceId()).append(", getLastConnection()=").append(getLastConnection())
+ .append(", isOnline()=").append(isOnline()).append(", getNameLong()=").append(getNameLong())
+ .append(", getSoftwareVersion()=").append(getSoftwareVersion()).append(", getStructureId()=")
+ .append(getStructureId()).append(", getWhereId()=").append(getWhereId()).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.openhab.binding.nest.internal.wwn.dto.WWNSmokeDetector.AlarmState;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The WWN structure details.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNStructure implements WWNIdentifiable {
+
+ private String structureId;
+ private List<String> thermostats;
+ private List<String> smokeCoAlarms;
+ private List<String> cameras;
+ private String countryCode;
+ private String postalCode;
+ private Date peakPeriodStartTime;
+ private Date peakPeriodEndTime;
+ private String timeZone;
+ private Date etaBegin;
+ private WWNSmokeDetector.AlarmState coAlarmState;
+ private WWNSmokeDetector.AlarmState smokeAlarmState;
+ private Boolean rhrEnrollment;
+ private Map<String, WWNWhere> wheres;
+ private HomeAwayState away;
+ private String name;
+ private WWNETA eta;
+ private SecurityState wwnSecurityState;
+
+ @Override
+ public String getId() {
+ return structureId;
+ }
+
+ public HomeAwayState getAway() {
+ return away;
+ }
+
+ public void setAway(HomeAwayState away) {
+ this.away = away;
+ }
+
+ public String getStructureId() {
+ return structureId;
+ }
+
+ public List<String> getThermostats() {
+ return thermostats;
+ }
+
+ public List<String> getSmokeCoAlarms() {
+ return smokeCoAlarms;
+ }
+
+ public List<String> getCameras() {
+ return cameras;
+ }
+
+ public String getCountryCode() {
+ return countryCode;
+ }
+
+ public String getPostalCode() {
+ return postalCode;
+ }
+
+ public Date getPeakPeriodStartTime() {
+ return peakPeriodStartTime;
+ }
+
+ public Date getPeakPeriodEndTime() {
+ return peakPeriodEndTime;
+ }
+
+ public String getTimeZone() {
+ return timeZone;
+ }
+
+ public Date getEtaBegin() {
+ return etaBegin;
+ }
+
+ public AlarmState getCoAlarmState() {
+ return coAlarmState;
+ }
+
+ public AlarmState getSmokeAlarmState() {
+ return smokeAlarmState;
+ }
+
+ public Boolean isRhrEnrollment() {
+ return rhrEnrollment;
+ }
+
+ public Map<String, WWNWhere> getWheres() {
+ return wheres;
+ }
+
+ public WWNETA getEta() {
+ return eta;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public SecurityState getWwnSecurityState() {
+ return wwnSecurityState;
+ }
+
+ public enum HomeAwayState {
+ @SerializedName("home")
+ HOME,
+ @SerializedName("away")
+ AWAY,
+ @SerializedName("unknown")
+ UNKNOWN
+ }
+
+ public enum SecurityState {
+ @SerializedName("ok")
+ OK,
+ @SerializedName("deter")
+ DETER
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNStructure other = (WWNStructure) obj;
+ if (away != other.away) {
+ return false;
+ }
+ if (cameras == null) {
+ if (other.cameras != null) {
+ return false;
+ }
+ } else if (!cameras.equals(other.cameras)) {
+ return false;
+ }
+ if (coAlarmState != other.coAlarmState) {
+ return false;
+ }
+ if (countryCode == null) {
+ if (other.countryCode != null) {
+ return false;
+ }
+ } else if (!countryCode.equals(other.countryCode)) {
+ return false;
+ }
+ if (eta == null) {
+ if (other.eta != null) {
+ return false;
+ }
+ } else if (!eta.equals(other.eta)) {
+ return false;
+ }
+ if (etaBegin == null) {
+ if (other.etaBegin != null) {
+ return false;
+ }
+ } else if (!etaBegin.equals(other.etaBegin)) {
+ return false;
+ }
+ if (name == null) {
+ if (other.name != null) {
+ return false;
+ }
+ } else if (!name.equals(other.name)) {
+ return false;
+ }
+ if (peakPeriodEndTime == null) {
+ if (other.peakPeriodEndTime != null) {
+ return false;
+ }
+ } else if (!peakPeriodEndTime.equals(other.peakPeriodEndTime)) {
+ return false;
+ }
+ if (peakPeriodStartTime == null) {
+ if (other.peakPeriodStartTime != null) {
+ return false;
+ }
+ } else if (!peakPeriodStartTime.equals(other.peakPeriodStartTime)) {
+ return false;
+ }
+ if (postalCode == null) {
+ if (other.postalCode != null) {
+ return false;
+ }
+ } else if (!postalCode.equals(other.postalCode)) {
+ return false;
+ }
+ if (rhrEnrollment == null) {
+ if (other.rhrEnrollment != null) {
+ return false;
+ }
+ } else if (!rhrEnrollment.equals(other.rhrEnrollment)) {
+ return false;
+ }
+ if (smokeAlarmState != other.smokeAlarmState) {
+ return false;
+ }
+ if (smokeCoAlarms == null) {
+ if (other.smokeCoAlarms != null) {
+ return false;
+ }
+ } else if (!smokeCoAlarms.equals(other.smokeCoAlarms)) {
+ return false;
+ }
+ if (structureId == null) {
+ if (other.structureId != null) {
+ return false;
+ }
+ } else if (!structureId.equals(other.structureId)) {
+ return false;
+ }
+ if (thermostats == null) {
+ if (other.thermostats != null) {
+ return false;
+ }
+ } else if (!thermostats.equals(other.thermostats)) {
+ return false;
+ }
+ if (timeZone == null) {
+ if (other.timeZone != null) {
+ return false;
+ }
+ } else if (!timeZone.equals(other.timeZone)) {
+ return false;
+ }
+ if (wheres == null) {
+ if (other.wheres != null) {
+ return false;
+ }
+ } else if (!wheres.equals(other.wheres)) {
+ return false;
+ }
+ return wwnSecurityState == other.wwnSecurityState;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((away == null) ? 0 : away.hashCode());
+ result = prime * result + ((cameras == null) ? 0 : cameras.hashCode());
+ result = prime * result + ((coAlarmState == null) ? 0 : coAlarmState.hashCode());
+ result = prime * result + ((countryCode == null) ? 0 : countryCode.hashCode());
+ result = prime * result + ((eta == null) ? 0 : eta.hashCode());
+ result = prime * result + ((etaBegin == null) ? 0 : etaBegin.hashCode());
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ result = prime * result + ((peakPeriodEndTime == null) ? 0 : peakPeriodEndTime.hashCode());
+ result = prime * result + ((peakPeriodStartTime == null) ? 0 : peakPeriodStartTime.hashCode());
+ result = prime * result + ((postalCode == null) ? 0 : postalCode.hashCode());
+ result = prime * result + ((rhrEnrollment == null) ? 0 : rhrEnrollment.hashCode());
+ result = prime * result + ((smokeAlarmState == null) ? 0 : smokeAlarmState.hashCode());
+ result = prime * result + ((smokeCoAlarms == null) ? 0 : smokeCoAlarms.hashCode());
+ result = prime * result + ((structureId == null) ? 0 : structureId.hashCode());
+ result = prime * result + ((thermostats == null) ? 0 : thermostats.hashCode());
+ result = prime * result + ((timeZone == null) ? 0 : timeZone.hashCode());
+ result = prime * result + ((wheres == null) ? 0 : wheres.hashCode());
+ result = prime * result + ((wwnSecurityState == null) ? 0 : wwnSecurityState.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Structure [structureId=").append(structureId).append(", thermostats=").append(thermostats)
+ .append(", smokeCoAlarms=").append(smokeCoAlarms).append(", cameras=").append(cameras)
+ .append(", countryCode=").append(countryCode).append(", postalCode=").append(postalCode)
+ .append(", peakPeriodStartTime=").append(peakPeriodStartTime).append(", peakPeriodEndTime=")
+ .append(peakPeriodEndTime).append(", timeZone=").append(timeZone).append(", etaBegin=").append(etaBegin)
+ .append(", coAlarmState=").append(coAlarmState).append(", smokeAlarmState=").append(smokeAlarmState)
+ .append(", rhrEnrollment=").append(rhrEnrollment).append(", wheres=").append(wheres).append(", away=")
+ .append(away).append(", name=").append(name).append(", eta=").append(eta).append(", wwnSecurityState=")
+ .append(wwnSecurityState).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
+import static org.openhab.core.library.unit.SIUnits.CELSIUS;
+
+import java.util.Date;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Temperature;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Gson class to encapsulate the data for the WWN thermostat.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNThermostat extends BaseWWNDevice {
+
+ private Boolean canCool;
+ private Boolean canHeat;
+ private Boolean isUsingEmergencyHeat;
+ private Boolean hasFan;
+ private Boolean fanTimerActive;
+ private Date fanTimerTimeout;
+ private Boolean hasLeaf;
+ private String temperatureScale;
+ private Double ambientTemperatureC;
+ private Double ambientTemperatureF;
+ private Integer humidity;
+ private Double targetTemperatureC;
+ private Double targetTemperatureF;
+ private Double targetTemperatureHighC;
+ private Double targetTemperatureHighF;
+ private Double targetTemperatureLowC;
+ private Double targetTemperatureLowF;
+ private Mode hvacMode;
+ private Mode previousHvacMode;
+ private State hvacState;
+ private Double ecoTemperatureHighC;
+ private Double ecoTemperatureHighF;
+ private Double ecoTemperatureLowC;
+ private Double ecoTemperatureLowF;
+ private Boolean isLocked;
+ private Double lockedTempMaxC;
+ private Double lockedTempMaxF;
+ private Double lockedTempMinC;
+ private Double lockedTempMinF;
+ private Boolean sunlightCorrectionEnabled;
+ private Boolean sunlightCorrectionActive;
+ private Integer fanTimerDuration;
+ private String timeToTarget;
+ private String whereName;
+
+ public Unit<Temperature> getTemperatureUnit() {
+ if ("C".equals(temperatureScale)) {
+ return CELSIUS;
+ } else if ("F".equals(temperatureScale)) {
+ return FAHRENHEIT;
+ } else {
+ return null;
+ }
+ }
+
+ public Double getTargetTemperature() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return targetTemperatureC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return targetTemperatureF;
+ } else {
+ return null;
+ }
+ }
+
+ public Double getTargetTemperatureHigh() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return targetTemperatureHighC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return targetTemperatureHighF;
+ } else {
+ return null;
+ }
+ }
+
+ public Double getTargetTemperatureLow() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return targetTemperatureLowC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return targetTemperatureLowF;
+ } else {
+ return null;
+ }
+ }
+
+ public Mode getMode() {
+ return hvacMode;
+ }
+
+ public Double getEcoTemperatureHigh() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return ecoTemperatureHighC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return ecoTemperatureHighF;
+ } else {
+ return null;
+ }
+ }
+
+ public Double getEcoTemperatureLow() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return ecoTemperatureLowC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return ecoTemperatureLowF;
+ } else {
+ return null;
+ }
+ }
+
+ public Boolean isLocked() {
+ return isLocked;
+ }
+
+ public Double getLockedTempMax() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return lockedTempMaxC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return lockedTempMaxF;
+ } else {
+ return null;
+ }
+ }
+
+ public Double getLockedTempMin() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return lockedTempMinC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return lockedTempMinF;
+ } else {
+ return null;
+ }
+ }
+
+ public Boolean isCanCool() {
+ return canCool;
+ }
+
+ public Boolean isCanHeat() {
+ return canHeat;
+ }
+
+ public Boolean isUsingEmergencyHeat() {
+ return isUsingEmergencyHeat;
+ }
+
+ public Boolean isHasFan() {
+ return hasFan;
+ }
+
+ public Boolean isFanTimerActive() {
+ return fanTimerActive;
+ }
+
+ public Date getFanTimerTimeout() {
+ return fanTimerTimeout;
+ }
+
+ public Boolean isHasLeaf() {
+ return hasLeaf;
+ }
+
+ public Mode getPreviousHvacMode() {
+ return previousHvacMode;
+ }
+
+ public State getHvacState() {
+ return hvacState;
+ }
+
+ public Boolean isSunlightCorrectionEnabled() {
+ return sunlightCorrectionEnabled;
+ }
+
+ public Boolean isSunlightCorrectionActive() {
+ return sunlightCorrectionActive;
+ }
+
+ public Integer getFanTimerDuration() {
+ return fanTimerDuration;
+ }
+
+ public Integer getTimeToTarget() {
+ return parseTimeToTarget(timeToTarget);
+ }
+
+ /*
+ * Turns the time to target string into a real value.
+ */
+ static Integer parseTimeToTarget(String timeToTarget) {
+ if (timeToTarget == null) {
+ return null;
+ } else if (timeToTarget.startsWith("~") || timeToTarget.startsWith("<") || timeToTarget.startsWith(">")) {
+ return Integer.valueOf(timeToTarget.substring(1));
+ }
+ return Integer.valueOf(timeToTarget);
+ }
+
+ public String getWhereName() {
+ return whereName;
+ }
+
+ public Double getAmbientTemperature() {
+ if (getTemperatureUnit() == CELSIUS) {
+ return ambientTemperatureC;
+ } else if (getTemperatureUnit() == FAHRENHEIT) {
+ return ambientTemperatureF;
+ } else {
+ return null;
+ }
+ }
+
+ public Integer getHumidity() {
+ return humidity;
+ }
+
+ public enum Mode {
+ @SerializedName("heat")
+ HEAT,
+ @SerializedName("cool")
+ COOL,
+ @SerializedName("heat-cool")
+ HEAT_COOL,
+ @SerializedName("eco")
+ ECO,
+ @SerializedName("off")
+ OFF
+ }
+
+ public enum State {
+ @SerializedName("heating")
+ HEATING,
+ @SerializedName("cooling")
+ COOLING,
+ @SerializedName("off")
+ OFF
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || !super.equals(obj)) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNThermostat other = (WWNThermostat) obj;
+ if (ambientTemperatureC == null) {
+ if (other.ambientTemperatureC != null) {
+ return false;
+ }
+ } else if (!ambientTemperatureC.equals(other.ambientTemperatureC)) {
+ return false;
+ }
+ if (ambientTemperatureF == null) {
+ if (other.ambientTemperatureF != null) {
+ return false;
+ }
+ } else if (!ambientTemperatureF.equals(other.ambientTemperatureF)) {
+ return false;
+ }
+ if (canCool == null) {
+ if (other.canCool != null) {
+ return false;
+ }
+ } else if (!canCool.equals(other.canCool)) {
+ return false;
+ }
+ if (canHeat == null) {
+ if (other.canHeat != null) {
+ return false;
+ }
+ } else if (!canHeat.equals(other.canHeat)) {
+ return false;
+ }
+ if (ecoTemperatureHighC == null) {
+ if (other.ecoTemperatureHighC != null) {
+ return false;
+ }
+ } else if (!ecoTemperatureHighC.equals(other.ecoTemperatureHighC)) {
+ return false;
+ }
+ if (ecoTemperatureHighF == null) {
+ if (other.ecoTemperatureHighF != null) {
+ return false;
+ }
+ } else if (!ecoTemperatureHighF.equals(other.ecoTemperatureHighF)) {
+ return false;
+ }
+ if (ecoTemperatureLowC == null) {
+ if (other.ecoTemperatureLowC != null) {
+ return false;
+ }
+ } else if (!ecoTemperatureLowC.equals(other.ecoTemperatureLowC)) {
+ return false;
+ }
+ if (ecoTemperatureLowF == null) {
+ if (other.ecoTemperatureLowF != null) {
+ return false;
+ }
+ } else if (!ecoTemperatureLowF.equals(other.ecoTemperatureLowF)) {
+ return false;
+ }
+ if (fanTimerActive == null) {
+ if (other.fanTimerActive != null) {
+ return false;
+ }
+ } else if (!fanTimerActive.equals(other.fanTimerActive)) {
+ return false;
+ }
+ if (fanTimerDuration == null) {
+ if (other.fanTimerDuration != null) {
+ return false;
+ }
+ } else if (!fanTimerDuration.equals(other.fanTimerDuration)) {
+ return false;
+ }
+ if (fanTimerTimeout == null) {
+ if (other.fanTimerTimeout != null) {
+ return false;
+ }
+ } else if (!fanTimerTimeout.equals(other.fanTimerTimeout)) {
+ return false;
+ }
+ if (hasFan == null) {
+ if (other.hasFan != null) {
+ return false;
+ }
+ } else if (!hasFan.equals(other.hasFan)) {
+ return false;
+ }
+ if (hasLeaf == null) {
+ if (other.hasLeaf != null) {
+ return false;
+ }
+ } else if (!hasLeaf.equals(other.hasLeaf)) {
+ return false;
+ }
+ if (humidity == null) {
+ if (other.humidity != null) {
+ return false;
+ }
+ } else if (!humidity.equals(other.humidity)) {
+ return false;
+ }
+ if (hvacMode != other.hvacMode) {
+ return false;
+ }
+ if (hvacState != other.hvacState) {
+ return false;
+ }
+ if (isLocked == null) {
+ if (other.isLocked != null) {
+ return false;
+ }
+ } else if (!isLocked.equals(other.isLocked)) {
+ return false;
+ }
+ if (isUsingEmergencyHeat == null) {
+ if (other.isUsingEmergencyHeat != null) {
+ return false;
+ }
+ } else if (!isUsingEmergencyHeat.equals(other.isUsingEmergencyHeat)) {
+ return false;
+ }
+ if (lockedTempMaxC == null) {
+ if (other.lockedTempMaxC != null) {
+ return false;
+ }
+ } else if (!lockedTempMaxC.equals(other.lockedTempMaxC)) {
+ return false;
+ }
+ if (lockedTempMaxF == null) {
+ if (other.lockedTempMaxF != null) {
+ return false;
+ }
+ } else if (!lockedTempMaxF.equals(other.lockedTempMaxF)) {
+ return false;
+ }
+ if (lockedTempMinC == null) {
+ if (other.lockedTempMinC != null) {
+ return false;
+ }
+ } else if (!lockedTempMinC.equals(other.lockedTempMinC)) {
+ return false;
+ }
+ if (lockedTempMinF == null) {
+ if (other.lockedTempMinF != null) {
+ return false;
+ }
+ } else if (!lockedTempMinF.equals(other.lockedTempMinF)) {
+ return false;
+ }
+ if (previousHvacMode != other.previousHvacMode) {
+ return false;
+ }
+ if (sunlightCorrectionActive == null) {
+ if (other.sunlightCorrectionActive != null) {
+ return false;
+ }
+ } else if (!sunlightCorrectionActive.equals(other.sunlightCorrectionActive)) {
+ return false;
+ }
+ if (sunlightCorrectionEnabled == null) {
+ if (other.sunlightCorrectionEnabled != null) {
+ return false;
+ }
+ } else if (!sunlightCorrectionEnabled.equals(other.sunlightCorrectionEnabled)) {
+ return false;
+ }
+ if (targetTemperatureC == null) {
+ if (other.targetTemperatureC != null) {
+ return false;
+ }
+ } else if (!targetTemperatureC.equals(other.targetTemperatureC)) {
+ return false;
+ }
+ if (targetTemperatureF == null) {
+ if (other.targetTemperatureF != null) {
+ return false;
+ }
+ } else if (!targetTemperatureF.equals(other.targetTemperatureF)) {
+ return false;
+ }
+ if (targetTemperatureHighC == null) {
+ if (other.targetTemperatureHighC != null) {
+ return false;
+ }
+ } else if (!targetTemperatureHighC.equals(other.targetTemperatureHighC)) {
+ return false;
+ }
+ if (targetTemperatureHighF == null) {
+ if (other.targetTemperatureHighF != null) {
+ return false;
+ }
+ } else if (!targetTemperatureHighF.equals(other.targetTemperatureHighF)) {
+ return false;
+ }
+ if (targetTemperatureLowC == null) {
+ if (other.targetTemperatureLowC != null) {
+ return false;
+ }
+ } else if (!targetTemperatureLowC.equals(other.targetTemperatureLowC)) {
+ return false;
+ }
+ if (targetTemperatureLowF == null) {
+ if (other.targetTemperatureLowF != null) {
+ return false;
+ }
+ } else if (!targetTemperatureLowF.equals(other.targetTemperatureLowF)) {
+ return false;
+ }
+ if (temperatureScale == null) {
+ if (other.temperatureScale != null) {
+ return false;
+ }
+ } else if (!temperatureScale.equals(other.temperatureScale)) {
+ return false;
+ }
+ if (timeToTarget == null) {
+ if (other.timeToTarget != null) {
+ return false;
+ }
+ } else if (!timeToTarget.equals(other.timeToTarget)) {
+ return false;
+ }
+ if (whereName == null) {
+ if (other.whereName != null) {
+ return false;
+ }
+ } else if (!whereName.equals(other.whereName)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = super.hashCode();
+ result = prime * result + ((ambientTemperatureC == null) ? 0 : ambientTemperatureC.hashCode());
+ result = prime * result + ((ambientTemperatureF == null) ? 0 : ambientTemperatureF.hashCode());
+ result = prime * result + ((canCool == null) ? 0 : canCool.hashCode());
+ result = prime * result + ((canHeat == null) ? 0 : canHeat.hashCode());
+ result = prime * result + ((ecoTemperatureHighC == null) ? 0 : ecoTemperatureHighC.hashCode());
+ result = prime * result + ((ecoTemperatureHighF == null) ? 0 : ecoTemperatureHighF.hashCode());
+ result = prime * result + ((ecoTemperatureLowC == null) ? 0 : ecoTemperatureLowC.hashCode());
+ result = prime * result + ((ecoTemperatureLowF == null) ? 0 : ecoTemperatureLowF.hashCode());
+ result = prime * result + ((fanTimerActive == null) ? 0 : fanTimerActive.hashCode());
+ result = prime * result + ((fanTimerDuration == null) ? 0 : fanTimerDuration.hashCode());
+ result = prime * result + ((fanTimerTimeout == null) ? 0 : fanTimerTimeout.hashCode());
+ result = prime * result + ((hasFan == null) ? 0 : hasFan.hashCode());
+ result = prime * result + ((hasLeaf == null) ? 0 : hasLeaf.hashCode());
+ result = prime * result + ((humidity == null) ? 0 : humidity.hashCode());
+ result = prime * result + ((hvacMode == null) ? 0 : hvacMode.hashCode());
+ result = prime * result + ((hvacState == null) ? 0 : hvacState.hashCode());
+ result = prime * result + ((isLocked == null) ? 0 : isLocked.hashCode());
+ result = prime * result + ((isUsingEmergencyHeat == null) ? 0 : isUsingEmergencyHeat.hashCode());
+ result = prime * result + ((lockedTempMaxC == null) ? 0 : lockedTempMaxC.hashCode());
+ result = prime * result + ((lockedTempMaxF == null) ? 0 : lockedTempMaxF.hashCode());
+ result = prime * result + ((lockedTempMinC == null) ? 0 : lockedTempMinC.hashCode());
+ result = prime * result + ((lockedTempMinF == null) ? 0 : lockedTempMinF.hashCode());
+ result = prime * result + ((previousHvacMode == null) ? 0 : previousHvacMode.hashCode());
+ result = prime * result + ((sunlightCorrectionActive == null) ? 0 : sunlightCorrectionActive.hashCode());
+ result = prime * result + ((sunlightCorrectionEnabled == null) ? 0 : sunlightCorrectionEnabled.hashCode());
+ result = prime * result + ((targetTemperatureC == null) ? 0 : targetTemperatureC.hashCode());
+ result = prime * result + ((targetTemperatureF == null) ? 0 : targetTemperatureF.hashCode());
+ result = prime * result + ((targetTemperatureHighC == null) ? 0 : targetTemperatureHighC.hashCode());
+ result = prime * result + ((targetTemperatureHighF == null) ? 0 : targetTemperatureHighF.hashCode());
+ result = prime * result + ((targetTemperatureLowC == null) ? 0 : targetTemperatureLowC.hashCode());
+ result = prime * result + ((targetTemperatureLowF == null) ? 0 : targetTemperatureLowF.hashCode());
+ result = prime * result + ((temperatureScale == null) ? 0 : temperatureScale.hashCode());
+ result = prime * result + ((timeToTarget == null) ? 0 : timeToTarget.hashCode());
+ result = prime * result + ((whereName == null) ? 0 : whereName.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Thermostat [canCool=").append(canCool).append(", canHeat=").append(canHeat)
+ .append(", isUsingEmergencyHeat=").append(isUsingEmergencyHeat).append(", hasFan=").append(hasFan)
+ .append(", fanTimerActive=").append(fanTimerActive).append(", fanTimerTimeout=").append(fanTimerTimeout)
+ .append(", hasLeaf=").append(hasLeaf).append(", temperatureScale=").append(temperatureScale)
+ .append(", ambientTemperatureC=").append(ambientTemperatureC).append(", ambientTemperatureF=")
+ .append(ambientTemperatureF).append(", humidity=").append(humidity).append(", targetTemperatureC=")
+ .append(targetTemperatureC).append(", targetTemperatureF=").append(targetTemperatureF)
+ .append(", targetTemperatureHighC=").append(targetTemperatureHighC).append(", targetTemperatureHighF=")
+ .append(targetTemperatureHighF).append(", targetTemperatureLowC=").append(targetTemperatureLowC)
+ .append(", targetTemperatureLowF=").append(targetTemperatureLowF).append(", hvacMode=").append(hvacMode)
+ .append(", previousHvacMode=").append(previousHvacMode).append(", hvacState=").append(hvacState)
+ .append(", ecoTemperatureHighC=").append(ecoTemperatureHighC).append(", ecoTemperatureHighF=")
+ .append(ecoTemperatureHighF).append(", ecoTemperatureLowC=").append(ecoTemperatureLowC)
+ .append(", ecoTemperatureLowF=").append(ecoTemperatureLowF).append(", isLocked=").append(isLocked)
+ .append(", lockedTempMaxC=").append(lockedTempMaxC).append(", lockedTempMaxF=").append(lockedTempMaxF)
+ .append(", lockedTempMinC=").append(lockedTempMinC).append(", lockedTempMinF=").append(lockedTempMinF)
+ .append(", sunlightCorrectionEnabled=").append(sunlightCorrectionEnabled)
+ .append(", sunlightCorrectionActive=").append(sunlightCorrectionActive).append(", fanTimerDuration=")
+ .append(fanTimerDuration).append(", timeToTarget=").append(timeToTarget).append(", whereName=")
+ .append(whereName).append(", getId()=").append(getId()).append(", getName()=").append(getName())
+ .append(", getDeviceId()=").append(getDeviceId()).append(", getLastConnection()=")
+ .append(getLastConnection()).append(", isOnline()=").append(isOnline()).append(", getNameLong()=")
+ .append(getNameLong()).append(", getSoftwareVersion()=").append(getSoftwareVersion())
+ .append(", getStructureId()=").append(getStructureId()).append(", getWhereId()=").append(getWhereId())
+ .append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.Map;
+
+/**
+ * The top level WWN data that is sent by Nest.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNTopLevelData {
+
+ private WWNDevices devices;
+ private WWNMetadata metadata;
+ private Map<String, WWNStructure> structures;
+
+ public WWNDevices getDevices() {
+ return devices;
+ }
+
+ public WWNMetadata getMetadata() {
+ return metadata;
+ }
+
+ public Map<String, WWNStructure> getStructures() {
+ return structures;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNTopLevelData other = (WWNTopLevelData) obj;
+ if (devices == null) {
+ if (other.devices != null) {
+ return false;
+ }
+ } else if (!devices.equals(other.devices)) {
+ return false;
+ }
+ if (metadata == null) {
+ if (other.metadata != null) {
+ return false;
+ }
+ } else if (!metadata.equals(other.metadata)) {
+ return false;
+ }
+ if (structures == null) {
+ if (other.structures != null) {
+ return false;
+ }
+ } else if (!structures.equals(other.structures)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((devices == null) ? 0 : devices.hashCode());
+ result = prime * result + ((metadata == null) ? 0 : metadata.hashCode());
+ result = prime * result + ((structures == null) ? 0 : structures.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("TopLevelData [devices=").append(devices).append(", metadata=").append(metadata)
+ .append(", structures=").append(structures).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * The top level WWN data that is sent by Nest to a streaming REST client using SSE.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Replace polling with REST streaming
+ * @author Wouter Born - Add equals and hashCode methods
+ */
+public class WWNTopLevelStreamingData {
+
+ private String path;
+ private WWNTopLevelData data;
+
+ public String getPath() {
+ return path;
+ }
+
+ public WWNTopLevelData getData() {
+ return data;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((data == null) ? 0 : data.hashCode());
+ result = prime * result + ((path == null) ? 0 : path.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNTopLevelStreamingData other = (WWNTopLevelStreamingData) obj;
+ if (data == null) {
+ if (other.data != null) {
+ return false;
+ }
+ } else if (!data.equals(other.data)) {
+ return false;
+ }
+ if (path == null) {
+ if (other.path != null) {
+ return false;
+ }
+ } else if (!path.equals(other.path)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("TopLevelStreamingData [path=").append(path).append(", data=").append(data).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Contains the data needed to do an WWN update request back to Nest.
+ *
+ * @author David Bennett - Initial contribution
+ */
+public class WWNUpdateRequest {
+ private final String updatePath;
+ private final Map<String, Object> values;
+
+ private WWNUpdateRequest(Builder builder) {
+ this.updatePath = builder.basePath + builder.identifier;
+ this.values = builder.values;
+ }
+
+ public String getUpdatePath() {
+ return updatePath;
+ }
+
+ public Map<String, Object> getValues() {
+ return values;
+ }
+
+ public static class Builder {
+ private String basePath;
+ private String identifier;
+ private Map<String, Object> values = new HashMap<>();
+
+ public Builder withBasePath(String basePath) {
+ this.basePath = basePath;
+ return this;
+ }
+
+ public Builder withIdentifier(String identifier) {
+ this.identifier = identifier;
+ return this;
+ }
+
+ public Builder withAdditionalValue(String field, Object value) {
+ values.put(field, value);
+ return this;
+ }
+
+ public WWNUpdateRequest build() {
+ return new WWNUpdateRequest(this);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+/**
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Extract Where object from Structure
+ * @author Wouter Born - Add equals, hashCode, toString methods
+ */
+public class WWNWhere {
+ private String whereId;
+ private String name;
+
+ public String getWhereId() {
+ return whereId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ WWNWhere other = (WWNWhere) obj;
+ if (name == null) {
+ if (other.name != null) {
+ return false;
+ }
+ } else if (!name.equals(other.name)) {
+ return false;
+ }
+ if (whereId == null) {
+ if (other.whereId != null) {
+ return false;
+ }
+ } else if (!whereId.equals(other.whereId)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((name == null) ? 0 : name.hashCode());
+ result = prime * result + ((whereId == null) ? 0 : whereId.hashCode());
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("Where [whereId=").append(whereId).append(", name=").append(name).append("]");
+ return builder.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Will be thrown when the bridge was unable to resolve the Nest redirect URL.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Improve exception handling while sending data
+ */
+@NonNullByDefault
+@SuppressWarnings("serial")
+public class FailedResolvingWWNUrlException extends Exception {
+ public FailedResolvingWWNUrlException(String message) {
+ super(message);
+ }
+
+ public FailedResolvingWWNUrlException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public FailedResolvingWWNUrlException(Throwable cause) {
+ super(cause);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Will be thrown when the bridge was unable to retrieve data.
+ *
+ * @author Martin van Wingerden - Initial contribution
+ * @author Martin van Wingerden - Added more centralized handling of failure when retrieving data
+ */
+@NonNullByDefault
+@SuppressWarnings("serial")
+public class FailedRetrievingWWNDataException extends Exception {
+
+ public FailedRetrievingWWNDataException(String message) {
+ super(message);
+ }
+
+ public FailedRetrievingWWNDataException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public FailedRetrievingWWNDataException(Throwable cause) {
+ super(cause);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Will be thrown when the bridge was unable to send data.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Improve exception handling while sending data
+ */
+@NonNullByDefault
+@SuppressWarnings("serial")
+public class FailedSendingWWNDataException extends Exception {
+ public FailedSendingWWNDataException(String message) {
+ super(message);
+ }
+
+ public FailedSendingWWNDataException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public FailedSendingWWNDataException(Throwable cause) {
+ super(cause);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Will be thrown when there is no valid access token and it was not possible to refresh it
+ *
+ * @author Martin van Wingerden - Initial contribution
+ * @author Martin van Wingerden - Added more centralized handling of invalid access tokens
+ */
+@NonNullByDefault
+@SuppressWarnings("serial")
+public class InvalidWWNAccessTokenException extends Exception {
+ public InvalidWWNAccessTokenException(Exception cause) {
+ super(cause);
+ }
+
+ public InvalidWWNAccessTokenException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public InvalidWWNAccessTokenException(String message) {
+ super(message);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.JSON_CONTENT_TYPE;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.WWNUtils;
+import org.openhab.binding.nest.internal.wwn.config.WWNAccountConfiguration;
+import org.openhab.binding.nest.internal.wwn.discovery.WWNDiscoveryService;
+import org.openhab.binding.nest.internal.wwn.dto.WWNErrorData;
+import org.openhab.binding.nest.internal.wwn.dto.WWNIdentifiable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNTopLevelData;
+import org.openhab.binding.nest.internal.wwn.dto.WWNUpdateRequest;
+import org.openhab.binding.nest.internal.wwn.exceptions.FailedResolvingWWNUrlException;
+import org.openhab.binding.nest.internal.wwn.exceptions.FailedSendingWWNDataException;
+import org.openhab.binding.nest.internal.wwn.exceptions.InvalidWWNAccessTokenException;
+import org.openhab.binding.nest.internal.wwn.listener.WWNStreamingDataListener;
+import org.openhab.binding.nest.internal.wwn.listener.WWNThingDataListener;
+import org.openhab.binding.nest.internal.wwn.rest.WWNAuthorizer;
+import org.openhab.binding.nest.internal.wwn.rest.WWNStreamingRestClient;
+import org.openhab.binding.nest.internal.wwn.update.WWNCompositeUpdateHandler;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.io.net.http.HttpUtil;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This account handler connects to Nest and handles all the WWN API requests. It pulls down the
+ * updated data, polls the system and does all the co-ordination with the other handlers
+ * to get the data updated to the correct things.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Martin van Wingerden - Use listeners not only for discovery but for all data processing
+ * @author Wouter Born - Improve exception and URL redirect handling
+ */
+@NonNullByDefault
+public class WWNAccountHandler extends BaseBridgeHandler implements WWNStreamingDataListener {
+
+ private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
+
+ private final Logger logger = LoggerFactory.getLogger(WWNAccountHandler.class);
+
+ private final ClientBuilder clientBuilder;
+ private final SseEventSourceFactory eventSourceFactory;
+ private final List<WWNUpdateRequest> nestUpdateRequests = new CopyOnWriteArrayList<>();
+ private final WWNCompositeUpdateHandler updateHandler = new WWNCompositeUpdateHandler(
+ this::getPresentThingsNestIds);
+
+ private @NonNullByDefault({}) WWNAuthorizer authorizer;
+ private @NonNullByDefault({}) WWNAccountConfiguration config;
+
+ private @Nullable ScheduledFuture<?> initializeJob;
+ private @Nullable ScheduledFuture<?> transmitJob;
+ private @Nullable WWNRedirectUrlSupplier redirectUrlSupplier;
+ private @Nullable WWNStreamingRestClient streamingRestClient;
+
+ /**
+ * Creates the bridge handler to connect to Nest.
+ *
+ * @param bridge The bridge to connect to Nest with.
+ */
+ public WWNAccountHandler(Bridge bridge, ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory) {
+ super(bridge);
+ this.clientBuilder = clientBuilder;
+ this.eventSourceFactory = eventSourceFactory;
+ }
+
+ /**
+ * Initialize the connection to Nest.
+ */
+ @Override
+ public void initialize() {
+ logger.debug("Initializing Nest bridge handler");
+
+ config = getConfigAs(WWNAccountConfiguration.class);
+ authorizer = new WWNAuthorizer(config);
+ updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Starting poll query");
+
+ initializeJob = scheduler.schedule(() -> {
+ try {
+ logger.debug("Product ID {}", config.productId);
+ logger.debug("Product Secret {}", config.productSecret);
+ logger.debug("Pincode {}", config.pincode);
+ logger.debug("Access Token {}", getExistingOrNewAccessToken());
+ redirectUrlSupplier = createRedirectUrlSupplier();
+ restartStreamingUpdates();
+ } catch (InvalidWWNAccessTokenException e) {
+ logger.debug("Invalid access token", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Token is invalid and could not be refreshed: " + e.getMessage());
+ }
+ }, 0, TimeUnit.SECONDS);
+
+ logger.debug("Finished initializing Nest bridge handler");
+ }
+
+ /**
+ * Clean up the handler.
+ */
+ @Override
+ public void dispose() {
+ logger.debug("Nest bridge disposed");
+ stopStreamingUpdates();
+
+ ScheduledFuture<?> localInitializeJob = initializeJob;
+ if (localInitializeJob != null && !localInitializeJob.isCancelled()) {
+ localInitializeJob.cancel(true);
+ initializeJob = null;
+ }
+
+ ScheduledFuture<?> localTransmitJob = transmitJob;
+ if (localTransmitJob != null && !localTransmitJob.isCancelled()) {
+ localTransmitJob.cancel(true);
+ transmitJob = null;
+ }
+
+ this.authorizer = null;
+ this.redirectUrlSupplier = null;
+ this.streamingRestClient = null;
+ }
+
+ public <T> boolean addThingDataListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
+ return updateHandler.addListener(dataClass, listener);
+ }
+
+ public <T> boolean addThingDataListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
+ return updateHandler.addListener(dataClass, nestId, listener);
+ }
+
+ /**
+ * Adds the update request into the queue for doing something with, send immediately if the queue is empty.
+ */
+ public void addUpdateRequest(WWNUpdateRequest request) {
+ nestUpdateRequests.add(request);
+ scheduleTransmitJobForPendingRequests();
+ }
+
+ protected WWNRedirectUrlSupplier createRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
+ return new WWNRedirectUrlSupplier(getHttpHeaders());
+ }
+
+ private String getExistingOrNewAccessToken() throws InvalidWWNAccessTokenException {
+ String accessToken = config.accessToken;
+ if (accessToken == null || accessToken.isEmpty()) {
+ accessToken = authorizer.getNewAccessToken();
+ config.accessToken = accessToken;
+ config.pincode = "";
+ // Update and save the access token in the bridge configuration
+ Configuration configuration = editConfiguration();
+ configuration.put(WWNAccountConfiguration.ACCESS_TOKEN, config.accessToken);
+ configuration.put(WWNAccountConfiguration.PINCODE, config.pincode);
+ updateConfiguration(configuration);
+ logger.debug("Retrieved new access token: {}", config.accessToken);
+ return accessToken;
+ } else {
+ logger.debug("Re-using access token from configuration: {}", accessToken);
+ return accessToken;
+ }
+ }
+
+ protected Properties getHttpHeaders() throws InvalidWWNAccessTokenException {
+ Properties httpHeaders = new Properties();
+ httpHeaders.put("Authorization", "Bearer " + getExistingOrNewAccessToken());
+ httpHeaders.put("Content-Type", JSON_CONTENT_TYPE);
+ return httpHeaders;
+ }
+
+ public @Nullable <T> T getLastUpdate(Class<T> dataClass, String nestId) {
+ return updateHandler.getLastUpdate(dataClass, nestId);
+ }
+
+ public <T> List<T> getLastUpdates(Class<T> dataClass) {
+ return updateHandler.getLastUpdates(dataClass);
+ }
+
+ private WWNRedirectUrlSupplier getOrCreateRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
+ WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
+ if (localRedirectUrlSupplier == null) {
+ localRedirectUrlSupplier = createRedirectUrlSupplier();
+ redirectUrlSupplier = localRedirectUrlSupplier;
+ }
+ return localRedirectUrlSupplier;
+ }
+
+ private Set<String> getPresentThingsNestIds() {
+ Set<String> nestIds = new HashSet<>();
+ for (Thing thing : getThing().getThings()) {
+ ThingHandler handler = thing.getHandler();
+ if (handler != null && thing.getStatusInfo().getStatusDetail() != ThingStatusDetail.GONE) {
+ nestIds.add(((WWNIdentifiable) handler).getId());
+ }
+ }
+ return nestIds;
+ }
+
+ @Override
+ public Collection<Class<? extends ThingHandlerService>> getServices() {
+ return List.of(WWNDiscoveryService.class);
+ }
+
+ /**
+ * Handles an incoming command update
+ */
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ logger.debug("Refresh command received");
+ updateHandler.resendLastUpdates();
+ }
+ }
+
+ private void jsonToPutUrl(WWNUpdateRequest request)
+ throws FailedSendingWWNDataException, InvalidWWNAccessTokenException, FailedResolvingWWNUrlException {
+ try {
+ WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
+ if (localRedirectUrlSupplier == null) {
+ throw new FailedResolvingWWNUrlException("redirectUrlSupplier is null");
+ }
+
+ String url = localRedirectUrlSupplier.getRedirectUrl() + request.getUpdatePath();
+ logger.debug("Putting data to: {}", url);
+
+ String jsonContent = WWNUtils.toJson(request.getValues());
+ logger.debug("PUT content: {}", jsonContent);
+
+ ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8));
+ String jsonResponse = HttpUtil.executeUrl("PUT", url, getHttpHeaders(), inputStream, JSON_CONTENT_TYPE,
+ REQUEST_TIMEOUT);
+ logger.debug("PUT response: {}", jsonResponse);
+
+ WWNErrorData error = WWNUtils.fromJson(jsonResponse, WWNErrorData.class);
+ if (error.getError() != null && !error.getError().isBlank()) {
+ logger.debug("Nest API error: {}", error);
+ logger.warn("Nest API error: {}", error.getMessage());
+ }
+ } catch (IOException e) {
+ throw new FailedSendingWWNDataException("Failed to send data", e);
+ }
+ }
+
+ @Override
+ public void onAuthorizationRevoked(String token) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Authorization token revoked: " + token);
+ }
+
+ @Override
+ public void onConnected() {
+ updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Streaming data connection established");
+ scheduleTransmitJobForPendingRequests();
+ }
+
+ @Override
+ public void onDisconnected() {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Streaming data disconnected");
+ }
+
+ @Override
+ public void onError(String message) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
+ }
+
+ @Override
+ public void onNewTopLevelData(WWNTopLevelData data) {
+ updateHandler.handleUpdate(data);
+ updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Receiving streaming data");
+ }
+
+ public <T> boolean removeThingDataListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
+ return updateHandler.removeListener(dataClass, listener);
+ }
+
+ public <T> boolean removeThingDataListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
+ return updateHandler.removeListener(dataClass, nestId, listener);
+ }
+
+ private void restartStreamingUpdates() {
+ synchronized (this) {
+ stopStreamingUpdates();
+ startStreamingUpdates();
+ }
+ }
+
+ private void scheduleTransmitJobForPendingRequests() {
+ ScheduledFuture<?> localTransmitJob = transmitJob;
+ if (!nestUpdateRequests.isEmpty() && (localTransmitJob == null || localTransmitJob.isDone())) {
+ transmitJob = scheduler.schedule(this::transmitQueue, 0, SECONDS);
+ }
+ }
+
+ private void startStreamingUpdates() {
+ synchronized (this) {
+ try {
+ WWNStreamingRestClient localStreamingRestClient = new WWNStreamingRestClient(
+ getExistingOrNewAccessToken(), clientBuilder, eventSourceFactory,
+ getOrCreateRedirectUrlSupplier(), scheduler);
+ localStreamingRestClient.addStreamingDataListener(this);
+ localStreamingRestClient.start();
+
+ streamingRestClient = localStreamingRestClient;
+ } catch (InvalidWWNAccessTokenException e) {
+ logger.debug("Invalid access token", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Token is invalid and could not be refreshed: " + e.getMessage());
+ }
+ }
+ }
+
+ private void stopStreamingUpdates() {
+ WWNStreamingRestClient localStreamingRestClient = streamingRestClient;
+ if (localStreamingRestClient != null) {
+ synchronized (this) {
+ localStreamingRestClient.stop();
+ localStreamingRestClient.removeStreamingDataListener(this);
+ streamingRestClient = null;
+ }
+ }
+ }
+
+ private void transmitQueue() {
+ if (getThing().getStatus() == ThingStatus.OFFLINE) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Not transmitting events because bridge is OFFLINE");
+ return;
+ }
+
+ try {
+ while (!nestUpdateRequests.isEmpty()) {
+ // nestUpdateRequests is a CopyOnWriteArrayList so its iterator does not support remove operations
+ WWNUpdateRequest request = nestUpdateRequests.get(0);
+ jsonToPutUrl(request);
+ nestUpdateRequests.remove(request);
+ }
+ } catch (InvalidWWNAccessTokenException e) {
+ logger.debug("Invalid access token", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Token is invalid and could not be refreshed: " + e.getMessage());
+ } catch (FailedResolvingWWNUrlException e) {
+ logger.debug("Unable to resolve redirect URL", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
+ } catch (FailedSendingWWNDataException e) {
+ logger.debug("Error sending data", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
+
+ WWNRedirectUrlSupplier localRedirectUrlSupplier = redirectUrlSupplier;
+ if (localRedirectUrlSupplier != null) {
+ localRedirectUrlSupplier.resetCache();
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.Date;
+import java.util.TimeZone;
+import java.util.stream.Collectors;
+
+import javax.measure.Quantity;
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.config.WWNDeviceConfiguration;
+import org.openhab.binding.nest.internal.wwn.dto.WWNIdentifiable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNUpdateRequest;
+import org.openhab.binding.nest.internal.wwn.listener.WWNThingDataListener;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Deals with the structures on the WWN API, turning them into a thing in openHAB.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Martin van Wingerden - Splitted of NestBaseHandler
+ * @author Wouter Born - Add generic update data type
+ *
+ * @param <T> the type of update data
+ */
+@NonNullByDefault
+public abstract class WWNBaseHandler<@NonNull T> extends BaseThingHandler
+ implements WWNThingDataListener<T>, WWNIdentifiable {
+ private final Logger logger = LoggerFactory.getLogger(WWNBaseHandler.class);
+
+ private String deviceId = "";
+ private Class<T> dataClass;
+
+ WWNBaseHandler(Thing thing, Class<T> dataClass) {
+ super(thing);
+ this.dataClass = dataClass;
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing handler for {}", getClass().getName());
+
+ WWNAccountHandler handler = getAccountHandler();
+ if (handler != null) {
+ boolean success = handler.addThingDataListener(dataClass, getId(), this);
+ logger.debug("Adding {} with ID '{}' as device data listener, result: {}", getClass().getSimpleName(),
+ getId(), success);
+ } else {
+ logger.debug("Unable to add {} with ID '{}' as device data listener because bridge is null",
+ getClass().getSimpleName(), getId());
+ }
+
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Waiting for refresh");
+
+ final @Nullable T lastUpdate = getLastUpdate();
+ if (lastUpdate != null) {
+ update(null, lastUpdate);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ WWNAccountHandler handler = getAccountHandler();
+ if (handler != null) {
+ handler.removeThingDataListener(dataClass, getId(), this);
+ }
+ }
+
+ protected @Nullable T getLastUpdate() {
+ WWNAccountHandler handler = getAccountHandler();
+ if (handler != null) {
+ return handler.getLastUpdate(dataClass, getId());
+ }
+ return null;
+ }
+
+ protected void addUpdateRequest(String updatePath, String field, Object value) {
+ WWNAccountHandler handler = getAccountHandler();
+ if (handler != null) {
+ handler.addUpdateRequest(new WWNUpdateRequest.Builder() //
+ .withBasePath(updatePath) //
+ .withIdentifier(getId()) //
+ .withAdditionalValue(field, value) //
+ .build());
+ }
+ }
+
+ @Override
+ public String getId() {
+ return getDeviceId();
+ }
+
+ protected String getDeviceId() {
+ String localDeviceId = deviceId;
+ if (localDeviceId.isEmpty()) {
+ localDeviceId = getConfigAs(WWNDeviceConfiguration.class).deviceId;
+ deviceId = localDeviceId;
+ }
+ return localDeviceId;
+ }
+
+ protected @Nullable WWNAccountHandler getAccountHandler() {
+ Bridge bridge = getBridge();
+ return bridge != null ? (WWNAccountHandler) bridge.getHandler() : null;
+ }
+
+ protected abstract State getChannelState(ChannelUID channelUID, T data);
+
+ protected State getAsDateTimeTypeOrNull(@Nullable Date date) {
+ if (date == null) {
+ return UnDefType.NULL;
+ }
+
+ long offsetMillis = TimeZone.getDefault().getOffset(date.getTime());
+ Instant instant = date.toInstant().plusMillis(offsetMillis);
+ return new DateTimeType(ZonedDateTime.ofInstant(instant, TimeZone.getDefault().toZoneId()));
+ }
+
+ protected State getAsDecimalTypeOrNull(@Nullable Integer value) {
+ return value == null ? UnDefType.NULL : new DecimalType(value);
+ }
+
+ protected State getAsOnOffTypeOrNull(@Nullable Boolean value) {
+ return value == null ? UnDefType.NULL : value ? OnOffType.ON : OnOffType.OFF;
+ }
+
+ protected <U extends Quantity<U>> State getAsQuantityTypeOrNull(@Nullable Number value, Unit<U> unit) {
+ return value == null ? UnDefType.NULL : new QuantityType<>(value, unit);
+ }
+
+ protected State getAsStringTypeOrNull(@Nullable Object value) {
+ return value == null ? UnDefType.NULL : new StringType(value.toString());
+ }
+
+ protected State getAsStringTypeListOrNull(@Nullable Collection<@NonNull ?> values) {
+ return values == null || values.isEmpty() ? UnDefType.NULL
+ : new StringType(values.stream().map(value -> value.toString()).collect(Collectors.joining(",")));
+ }
+
+ protected boolean isNotHandling(WWNIdentifiable nestIdentifiable) {
+ return !(getId().equals(nestIdentifiable.getId()));
+ }
+
+ protected void updateLinkedChannels(@Nullable T oldData, T data) {
+ getThing().getChannels().stream().map(channel -> channel.getUID()).filter(this::isLinked)
+ .forEach(channelUID -> {
+ State newState = getChannelState(channelUID, data);
+ if (oldData == null || !getChannelState(channelUID, oldData).equals(newState)) {
+ logger.debug("Updating {}", channelUID);
+ updateState(channelUID, newState);
+ }
+ });
+ }
+
+ @Override
+ public void onNewData(T data) {
+ update(null, data);
+ }
+
+ @Override
+ public void onUpdatedData(T oldData, T data) {
+ update(oldData, data);
+ }
+
+ @Override
+ public void onMissingData(String nestId) {
+ thing.setStatusInfo(
+ new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Missing from streaming updates"));
+ }
+
+ protected abstract void update(@Nullable T oldData, T data);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNCamera;
+import org.openhab.binding.nest.internal.wwn.dto.WWNCameraEvent;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles all the updates to the camera as well as handling the commands that send updates to the WWN API.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Handle channel refresh command
+ */
+@NonNullByDefault
+public class WWNCameraHandler extends WWNBaseHandler<WWNCamera> {
+ private final Logger logger = LoggerFactory.getLogger(WWNCameraHandler.class);
+
+ public WWNCameraHandler(Thing thing) {
+ super(thing, WWNCamera.class);
+ }
+
+ @Override
+ protected State getChannelState(ChannelUID channelUID, WWNCamera camera) {
+ if (channelUID.getId().startsWith(CHANNEL_GROUP_CAMERA_PREFIX)) {
+ return getCameraChannelState(channelUID, camera);
+ } else if (channelUID.getId().startsWith(CHANNEL_GROUP_LAST_EVENT_PREFIX)) {
+ return getLastEventChannelState(channelUID, camera);
+ } else {
+ logger.error("Unsupported channelId '{}'", channelUID.getId());
+ return UnDefType.UNDEF;
+ }
+ }
+
+ protected State getCameraChannelState(ChannelUID channelUID, WWNCamera camera) {
+ switch (channelUID.getId()) {
+ case CHANNEL_CAMERA_APP_URL:
+ return getAsStringTypeOrNull(camera.getAppUrl());
+ case CHANNEL_CAMERA_AUDIO_INPUT_ENABLED:
+ return getAsOnOffTypeOrNull(camera.isAudioInputEnabled());
+ case CHANNEL_CAMERA_LAST_ONLINE_CHANGE:
+ return getAsDateTimeTypeOrNull(camera.getLastIsOnlineChange());
+ case CHANNEL_CAMERA_PUBLIC_SHARE_ENABLED:
+ return getAsOnOffTypeOrNull(camera.isPublicShareEnabled());
+ case CHANNEL_CAMERA_PUBLIC_SHARE_URL:
+ return getAsStringTypeOrNull(camera.getPublicShareUrl());
+ case CHANNEL_CAMERA_SNAPSHOT_URL:
+ return getAsStringTypeOrNull(camera.getSnapshotUrl());
+ case CHANNEL_CAMERA_STREAMING:
+ return getAsOnOffTypeOrNull(camera.isStreaming());
+ case CHANNEL_CAMERA_VIDEO_HISTORY_ENABLED:
+ return getAsOnOffTypeOrNull(camera.isVideoHistoryEnabled());
+ case CHANNEL_CAMERA_WEB_URL:
+ return getAsStringTypeOrNull(camera.getWebUrl());
+ default:
+ logger.error("Unsupported channelId '{}'", channelUID.getId());
+ return UnDefType.UNDEF;
+ }
+ }
+
+ protected State getLastEventChannelState(ChannelUID channelUID, WWNCamera camera) {
+ WWNCameraEvent lastEvent = camera.getLastEvent();
+ if (lastEvent == null) {
+ return UnDefType.NULL;
+ }
+
+ switch (channelUID.getId()) {
+ case CHANNEL_LAST_EVENT_ACTIVITY_ZONES:
+ return getAsStringTypeListOrNull(lastEvent.getActivityZones());
+ case CHANNEL_LAST_EVENT_ANIMATED_IMAGE_URL:
+ return getAsStringTypeOrNull(lastEvent.getAnimatedImageUrl());
+ case CHANNEL_LAST_EVENT_APP_URL:
+ return getAsStringTypeOrNull(lastEvent.getAppUrl());
+ case CHANNEL_LAST_EVENT_END_TIME:
+ return getAsDateTimeTypeOrNull(lastEvent.getEndTime());
+ case CHANNEL_LAST_EVENT_HAS_MOTION:
+ return getAsOnOffTypeOrNull(lastEvent.isHasMotion());
+ case CHANNEL_LAST_EVENT_HAS_PERSON:
+ return getAsOnOffTypeOrNull(lastEvent.isHasPerson());
+ case CHANNEL_LAST_EVENT_HAS_SOUND:
+ return getAsOnOffTypeOrNull(lastEvent.isHasSound());
+ case CHANNEL_LAST_EVENT_IMAGE_URL:
+ return getAsStringTypeOrNull(lastEvent.getImageUrl());
+ case CHANNEL_LAST_EVENT_START_TIME:
+ return getAsDateTimeTypeOrNull(lastEvent.getStartTime());
+ case CHANNEL_LAST_EVENT_URLS_EXPIRE_TIME:
+ return getAsDateTimeTypeOrNull(lastEvent.getUrlsExpireTime());
+ case CHANNEL_LAST_EVENT_WEB_URL:
+ return getAsStringTypeOrNull(lastEvent.getWebUrl());
+ default:
+ logger.error("Unsupported channelId '{}'", channelUID.getId());
+ return UnDefType.UNDEF;
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (REFRESH.equals(command)) {
+ WWNCamera lastUpdate = getLastUpdate();
+ if (lastUpdate != null) {
+ updateState(channelUID, getChannelState(channelUID, lastUpdate));
+ }
+ } else if (CHANNEL_CAMERA_STREAMING.equals(channelUID.getId())) {
+ // Change the mode.
+ if (command instanceof OnOffType) {
+ // Set the mode to be the cmd value.
+ addUpdateRequest("is_streaming", command == OnOffType.ON);
+ }
+ }
+ }
+
+ private void addUpdateRequest(String field, Object value) {
+ addUpdateRequest(NEST_CAMERA_UPDATE_PATH, field, value);
+ }
+
+ @Override
+ protected void update(@Nullable WWNCamera oldCamera, WWNCamera camera) {
+ logger.debug("Updating {}", getThing().getUID());
+
+ updateLinkedChannels(oldCamera, camera);
+ updateProperty(PROPERTY_FIRMWARE_VERSION, camera.getSoftwareVersion());
+
+ ThingStatus newStatus = camera.isOnline() == null ? ThingStatus.UNKNOWN
+ : camera.isOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
+ if (newStatus != thing.getStatus()) {
+ updateStatus(newStatus);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.openhab.binding.nest.internal.wwn.WWNBindingConstants;
+import org.openhab.binding.nest.internal.wwn.exceptions.FailedResolvingWWNUrlException;
+import org.openhab.core.io.net.http.HttpUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Supplies resolved redirect URLs of {@link WWNBindingConstants#NEST_URL} so they can be used with HTTP clients that
+ * do not pass Authorization headers after redirects like the Jetty client used by {@link HttpUtil}.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Extract resolving redirect URL from NestBridgeHandler into NestRedirectUrlSupplier
+ */
+@NonNullByDefault
+public class WWNRedirectUrlSupplier {
+
+ private final Logger logger = LoggerFactory.getLogger(WWNRedirectUrlSupplier.class);
+
+ protected String cachedUrl = "";
+
+ protected Properties httpHeaders;
+
+ public WWNRedirectUrlSupplier(Properties httpHeaders) {
+ this.httpHeaders = httpHeaders;
+ }
+
+ public String getRedirectUrl() throws FailedResolvingWWNUrlException {
+ if (cachedUrl.isEmpty()) {
+ cachedUrl = resolveRedirectUrl();
+ }
+ return cachedUrl;
+ }
+
+ public void resetCache() {
+ cachedUrl = "";
+ }
+
+ /**
+ * Resolves the redirect URL for calls using the {@link WWNBindingConstants#NEST_URL}.
+ *
+ * The Jetty client used by {@link HttpUtil} will not pass the Authorization header after a redirect resulting in
+ * "401 Unauthorized error" issues.
+ *
+ * Note that this workaround currently does not use any configured proxy like {@link HttpUtil} does.
+ *
+ * @see https://developers.nest.com/documentation/cloud/how-to-handle-redirects
+ */
+ private String resolveRedirectUrl() throws FailedResolvingWWNUrlException {
+ HttpClient httpClient = new HttpClient(new SslContextFactory.Client());
+ httpClient.setFollowRedirects(false);
+
+ Request request = httpClient.newRequest(WWNBindingConstants.NEST_URL).method(HttpMethod.GET).timeout(30,
+ TimeUnit.SECONDS);
+ for (String httpHeaderKey : httpHeaders.stringPropertyNames()) {
+ request.header(httpHeaderKey, httpHeaders.getProperty(httpHeaderKey));
+ }
+
+ ContentResponse response;
+ try {
+ httpClient.start();
+ response = request.send();
+ httpClient.stop();
+ } catch (Exception e) {
+ throw new FailedResolvingWWNUrlException("Failed to resolve redirect URL: " + e.getMessage(), e);
+ }
+
+ int status = response.getStatus();
+ String redirectUrl = response.getHeaders().get(HttpHeader.LOCATION);
+
+ if (status != HttpStatus.TEMPORARY_REDIRECT_307) {
+ logger.debug("Redirect status: {}", status);
+ logger.debug("Redirect response: {}", response.getContentAsString());
+ throw new FailedResolvingWWNUrlException("Failed to get redirect URL, expected status "
+ + HttpStatus.TEMPORARY_REDIRECT_307 + " but was " + status);
+ } else if (redirectUrl == null || redirectUrl.isEmpty()) {
+ throw new FailedResolvingWWNUrlException("Redirect URL is empty");
+ }
+
+ redirectUrl = redirectUrl.endsWith("/") ? redirectUrl.substring(0, redirectUrl.length() - 1) : redirectUrl;
+ logger.debug("Redirect URL: {}", redirectUrl);
+ return redirectUrl;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNSmokeDetector;
+import org.openhab.binding.nest.internal.wwn.dto.WWNSmokeDetector.BatteryHealth;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The smoke detector handler, it handles the data from WWN for the smoke detector.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Handle channel refresh command
+ */
+@NonNullByDefault
+public class WWNSmokeDetectorHandler extends WWNBaseHandler<WWNSmokeDetector> {
+ private final Logger logger = LoggerFactory.getLogger(WWNSmokeDetectorHandler.class);
+
+ public WWNSmokeDetectorHandler(Thing thing) {
+ super(thing, WWNSmokeDetector.class);
+ }
+
+ @Override
+ protected State getChannelState(ChannelUID channelUID, WWNSmokeDetector smokeDetector) {
+ switch (channelUID.getId()) {
+ case CHANNEL_CO_ALARM_STATE:
+ return getAsStringTypeOrNull(smokeDetector.getCoAlarmState());
+ case CHANNEL_LAST_CONNECTION:
+ return getAsDateTimeTypeOrNull(smokeDetector.getLastConnection());
+ case CHANNEL_LAST_MANUAL_TEST_TIME:
+ return getAsDateTimeTypeOrNull(smokeDetector.getLastManualTestTime());
+ case CHANNEL_LOW_BATTERY:
+ return getAsOnOffTypeOrNull(smokeDetector.getBatteryHealth() == null ? null
+ : smokeDetector.getBatteryHealth() == BatteryHealth.REPLACE);
+ case CHANNEL_MANUAL_TEST_ACTIVE:
+ return getAsOnOffTypeOrNull(smokeDetector.isManualTestActive());
+ case CHANNEL_SMOKE_ALARM_STATE:
+ return getAsStringTypeOrNull(smokeDetector.getSmokeAlarmState());
+ case CHANNEL_UI_COLOR_STATE:
+ return getAsStringTypeOrNull(smokeDetector.getUiColorState());
+ default:
+ logger.error("Unsupported channelId '{}'", channelUID.getId());
+ return UnDefType.UNDEF;
+ }
+ }
+
+ /**
+ * Handles any incoming command requests.
+ */
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (REFRESH.equals(command)) {
+ WWNSmokeDetector lastUpdate = getLastUpdate();
+ if (lastUpdate != null) {
+ updateState(channelUID, getChannelState(channelUID, lastUpdate));
+ }
+ }
+ }
+
+ @Override
+ protected void update(@Nullable WWNSmokeDetector oldSmokeDetector, WWNSmokeDetector smokeDetector) {
+ logger.debug("Updating {}", getThing().getUID());
+
+ updateLinkedChannels(oldSmokeDetector, smokeDetector);
+ updateProperty(PROPERTY_FIRMWARE_VERSION, smokeDetector.getSoftwareVersion());
+
+ ThingStatus newStatus = smokeDetector.isOnline() == null ? ThingStatus.UNKNOWN
+ : smokeDetector.isOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
+ if (newStatus != thing.getStatus()) {
+ updateStatus(newStatus);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.config.WWNStructureConfiguration;
+import org.openhab.binding.nest.internal.wwn.dto.WWNStructure;
+import org.openhab.binding.nest.internal.wwn.dto.WWNStructure.HomeAwayState;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Deals with the structures on the WWN API, turning them into a thing in openHAB.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Handle channel refresh command
+ */
+@NonNullByDefault
+public class WWNStructureHandler extends WWNBaseHandler<WWNStructure> {
+ private final Logger logger = LoggerFactory.getLogger(WWNStructureHandler.class);
+
+ private @Nullable String structureId;
+
+ public WWNStructureHandler(Thing thing) {
+ super(thing, WWNStructure.class);
+ }
+
+ @Override
+ protected State getChannelState(ChannelUID channelUID, WWNStructure structure) {
+ switch (channelUID.getId()) {
+ case CHANNEL_AWAY:
+ return getAsStringTypeOrNull(structure.getAway());
+ case CHANNEL_CO_ALARM_STATE:
+ return getAsStringTypeOrNull(structure.getCoAlarmState());
+ case CHANNEL_COUNTRY_CODE:
+ return getAsStringTypeOrNull(structure.getCountryCode());
+ case CHANNEL_ETA_BEGIN:
+ return getAsDateTimeTypeOrNull(structure.getEtaBegin());
+ case CHANNEL_PEAK_PERIOD_END_TIME:
+ return getAsDateTimeTypeOrNull(structure.getPeakPeriodEndTime());
+ case CHANNEL_PEAK_PERIOD_START_TIME:
+ return getAsDateTimeTypeOrNull(structure.getPeakPeriodStartTime());
+ case CHANNEL_POSTAL_CODE:
+ return getAsStringTypeOrNull(structure.getPostalCode());
+ case CHANNEL_RUSH_HOUR_REWARDS_ENROLLMENT:
+ return getAsOnOffTypeOrNull(structure.isRhrEnrollment());
+ case CHANNEL_SECURITY_STATE:
+ return getAsStringTypeOrNull(structure.getWwnSecurityState());
+ case CHANNEL_SMOKE_ALARM_STATE:
+ return getAsStringTypeOrNull(structure.getSmokeAlarmState());
+ case CHANNEL_TIME_ZONE:
+ return getAsStringTypeOrNull(structure.getTimeZone());
+ default:
+ logger.error("Unsupported channelId '{}'", channelUID.getId());
+ return UnDefType.UNDEF;
+ }
+ }
+
+ @Override
+ public String getId() {
+ return getStructureId();
+ }
+
+ private String getStructureId() {
+ String localStructureId = structureId;
+ if (localStructureId == null) {
+ localStructureId = getConfigAs(WWNStructureConfiguration.class).structureId;
+ structureId = localStructureId;
+ }
+ return localStructureId;
+ }
+
+ /**
+ * Handles updating the details on this structure by sending the request all the way
+ * to Nest.
+ *
+ * @param channelUID the channel to update
+ * @param command the command to apply
+ */
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (REFRESH.equals(command)) {
+ WWNStructure lastUpdate = getLastUpdate();
+ if (lastUpdate != null) {
+ updateState(channelUID, getChannelState(channelUID, lastUpdate));
+ }
+ } else if (CHANNEL_AWAY.equals(channelUID.getId())) {
+ // Change the home/away state.
+ if (command instanceof StringType) {
+ StringType cmd = (StringType) command;
+ // Set the mode to be the cmd value.
+ addUpdateRequest(NEST_STRUCTURE_UPDATE_PATH, "away", HomeAwayState.valueOf(cmd.toString()));
+ }
+ }
+ }
+
+ @Override
+ protected void update(@Nullable WWNStructure oldStructure, WWNStructure structure) {
+ logger.debug("Updating {}", getThing().getUID());
+
+ updateLinkedChannels(oldStructure, structure);
+
+ if (ThingStatus.ONLINE != thing.getStatus()) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.core.library.unit.SIUnits.CELSIUS;
+import static org.openhab.core.thing.Thing.PROPERTY_FIRMWARE_VERSION;
+import static org.openhab.core.types.RefreshType.REFRESH;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Temperature;
+import javax.measure.quantity.Time;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNThermostat;
+import org.openhab.binding.nest.internal.wwn.dto.WWNThermostat.Mode;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WWNThermostatHandler} is responsible for handling commands, which are
+ * sent to one of the channels for the thermostat.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Handle channel refresh command
+ */
+@NonNullByDefault
+public class WWNThermostatHandler extends WWNBaseHandler<WWNThermostat> {
+ private final Logger logger = LoggerFactory.getLogger(WWNThermostatHandler.class);
+
+ public WWNThermostatHandler(Thing thing) {
+ super(thing, WWNThermostat.class);
+ }
+
+ @Override
+ protected State getChannelState(ChannelUID channelUID, WWNThermostat thermostat) {
+ switch (channelUID.getId()) {
+ case CHANNEL_CAN_COOL:
+ return getAsOnOffTypeOrNull(thermostat.isCanCool());
+ case CHANNEL_CAN_HEAT:
+ return getAsOnOffTypeOrNull(thermostat.isCanHeat());
+ case CHANNEL_ECO_MAX_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getEcoTemperatureHigh(), thermostat.getTemperatureUnit());
+ case CHANNEL_ECO_MIN_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getEcoTemperatureLow(), thermostat.getTemperatureUnit());
+ case CHANNEL_FAN_TIMER_ACTIVE:
+ return getAsOnOffTypeOrNull(thermostat.isFanTimerActive());
+ case CHANNEL_FAN_TIMER_DURATION:
+ return getAsQuantityTypeOrNull(thermostat.getFanTimerDuration(), Units.MINUTE);
+ case CHANNEL_FAN_TIMER_TIMEOUT:
+ return getAsDateTimeTypeOrNull(thermostat.getFanTimerTimeout());
+ case CHANNEL_HAS_FAN:
+ return getAsOnOffTypeOrNull(thermostat.isHasFan());
+ case CHANNEL_HAS_LEAF:
+ return getAsOnOffTypeOrNull(thermostat.isHasLeaf());
+ case CHANNEL_HUMIDITY:
+ return getAsQuantityTypeOrNull(thermostat.getHumidity(), Units.PERCENT);
+ case CHANNEL_LAST_CONNECTION:
+ return getAsDateTimeTypeOrNull(thermostat.getLastConnection());
+ case CHANNEL_LOCKED:
+ return getAsOnOffTypeOrNull(thermostat.isLocked());
+ case CHANNEL_LOCKED_MAX_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getLockedTempMax(), thermostat.getTemperatureUnit());
+ case CHANNEL_LOCKED_MIN_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getLockedTempMin(), thermostat.getTemperatureUnit());
+ case CHANNEL_MAX_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getTargetTemperatureHigh(), thermostat.getTemperatureUnit());
+ case CHANNEL_MIN_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getTargetTemperatureLow(), thermostat.getTemperatureUnit());
+ case CHANNEL_MODE:
+ return getAsStringTypeOrNull(thermostat.getMode());
+ case CHANNEL_PREVIOUS_MODE:
+ Mode previousMode = thermostat.getPreviousHvacMode() != null ? thermostat.getPreviousHvacMode()
+ : thermostat.getMode();
+ return getAsStringTypeOrNull(previousMode);
+ case CHANNEL_STATE:
+ return getAsStringTypeOrNull(thermostat.getHvacState());
+ case CHANNEL_SET_POINT:
+ return getAsQuantityTypeOrNull(thermostat.getTargetTemperature(), thermostat.getTemperatureUnit());
+ case CHANNEL_SUNLIGHT_CORRECTION_ACTIVE:
+ return getAsOnOffTypeOrNull(thermostat.isSunlightCorrectionActive());
+ case CHANNEL_SUNLIGHT_CORRECTION_ENABLED:
+ return getAsOnOffTypeOrNull(thermostat.isSunlightCorrectionEnabled());
+ case CHANNEL_TEMPERATURE:
+ return getAsQuantityTypeOrNull(thermostat.getAmbientTemperature(), thermostat.getTemperatureUnit());
+ case CHANNEL_TIME_TO_TARGET:
+ return getAsQuantityTypeOrNull(thermostat.getTimeToTarget(), Units.MINUTE);
+ case CHANNEL_USING_EMERGENCY_HEAT:
+ return getAsOnOffTypeOrNull(thermostat.isUsingEmergencyHeat());
+ default:
+ logger.error("Unsupported channelId '{}'", channelUID.getId());
+ return UnDefType.UNDEF;
+ }
+ }
+
+ /**
+ * Handle the command to do things to the thermostat, this will change the
+ * value of a channel by sending the request to Nest.
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (REFRESH.equals(command)) {
+ WWNThermostat lastUpdate = getLastUpdate();
+ if (lastUpdate != null) {
+ updateState(channelUID, getChannelState(channelUID, lastUpdate));
+ }
+ } else if (CHANNEL_FAN_TIMER_ACTIVE.equals(channelUID.getId())) {
+ if (command instanceof OnOffType) {
+ // Update fan timer active to the command value
+ addUpdateRequest("fan_timer_active", command == OnOffType.ON);
+ }
+ } else if (CHANNEL_FAN_TIMER_DURATION.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ // Update fan timer duration to the command value
+ QuantityType<Time> minuteQuantity = ((QuantityType<Time>) command).toUnit(Units.MINUTE);
+ if (minuteQuantity != null) {
+ addUpdateRequest("fan_timer_duration", minuteQuantity.intValue());
+ }
+ }
+ } else if (CHANNEL_MAX_SET_POINT.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ // Update maximum set point to the command value
+ addTemperatureUpdateRequest("target_temperature_high_c", "target_temperature_high_f",
+ (QuantityType<Temperature>) command);
+ }
+ } else if (CHANNEL_MIN_SET_POINT.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ // Update minimum set point to the command value
+ addTemperatureUpdateRequest("target_temperature_low_c", "target_temperature_low_f",
+ (QuantityType<Temperature>) command);
+ }
+ } else if (CHANNEL_MODE.equals(channelUID.getId())) {
+ if (command instanceof StringType) {
+ // Update the HVAC mode to the command value
+ addUpdateRequest("hvac_mode", Mode.valueOf(((StringType) command).toString()));
+ }
+ } else if (CHANNEL_SET_POINT.equals(channelUID.getId())) {
+ if (command instanceof QuantityType) {
+ // Update set point to the command value
+ addTemperatureUpdateRequest("target_temperature_c", "target_temperature_f",
+ (QuantityType<Temperature>) command);
+ }
+ }
+ }
+
+ private void addUpdateRequest(String field, Object value) {
+ addUpdateRequest(NEST_THERMOSTAT_UPDATE_PATH, field, value);
+ }
+
+ private void addTemperatureUpdateRequest(String celsiusField, String fahrenheitField,
+ QuantityType<Temperature> quantity) {
+ Unit<Temperature> unit = getTemperatureUnit(quantity.getUnit());
+ BigDecimal value = quantityToRoundedTemperature(quantity, unit);
+ if (value != null) {
+ addUpdateRequest(NEST_THERMOSTAT_UPDATE_PATH, unit == CELSIUS ? celsiusField : fahrenheitField, value);
+ }
+ }
+
+ private Unit<Temperature> getTemperatureUnit(Unit<Temperature> fallbackUnit) {
+ WWNThermostat lastUpdate = getLastUpdate();
+ if (lastUpdate != null && lastUpdate.getTemperatureUnit() != null) {
+ return lastUpdate.getTemperatureUnit();
+ }
+
+ return fallbackUnit;
+ }
+
+ private @Nullable BigDecimal quantityToRoundedTemperature(QuantityType<Temperature> quantity,
+ Unit<Temperature> unit) throws IllegalArgumentException {
+ QuantityType<Temperature> temparatureQuantity = quantity.toUnit(unit);
+ if (temparatureQuantity == null) {
+ return null;
+ }
+
+ BigDecimal value = temparatureQuantity.toBigDecimal();
+ BigDecimal increment = CELSIUS == unit ? new BigDecimal("0.5") : new BigDecimal("1");
+ BigDecimal divisor = value.divide(increment, 0, RoundingMode.HALF_UP);
+ return divisor.multiply(increment);
+ }
+
+ @Override
+ protected void update(@Nullable WWNThermostat oldThermostat, WWNThermostat thermostat) {
+ logger.debug("Updating {}", getThing().getUID());
+
+ updateLinkedChannels(oldThermostat, thermostat);
+ updateProperty(PROPERTY_FIRMWARE_VERSION, thermostat.getSoftwareVersion());
+
+ ThingStatus newStatus = thermostat.isOnline() == null ? ThingStatus.UNKNOWN
+ : thermostat.isOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE;
+ if (newStatus != thing.getStatus()) {
+ updateStatus(newStatus);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.listener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nest.internal.wwn.dto.WWNTopLevelData;
+import org.openhab.binding.nest.internal.wwn.rest.WWNStreamingRestClient;
+
+/**
+ * Interface for listeners of events generated by the {@link WWNStreamingRestClient}.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Replace polling with REST streaming
+ */
+@NonNullByDefault
+public interface WWNStreamingDataListener {
+
+ /**
+ * Authorization has been revoked for a token.
+ */
+ void onAuthorizationRevoked(String token);
+
+ /**
+ * The client successfully established a connection.
+ */
+ void onConnected();
+
+ /**
+ * The client was disconnected.
+ */
+ void onDisconnected();
+
+ /**
+ * An error message was published.
+ */
+ void onError(String message);
+
+ /**
+ * Initial {@link WWNTopLevelData} or an update is sent.
+ */
+ void onNewTopLevelData(WWNTopLevelData data);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.listener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Used to track incoming data for WWN things.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public interface WWNThingDataListener<T> {
+
+ /**
+ * An initial value for the data was received or the value is send again due to a refresh.
+ *
+ * @param data the data
+ */
+ void onNewData(T data);
+
+ /**
+ * Existing data was updated to a new value.
+ *
+ * @param oldData the previous value
+ * @param data the current value
+ */
+ void onUpdatedData(T oldData, T data);
+
+ /**
+ * A Nest thing which previously had data is missing. E.g. it was removed from the account.
+ *
+ * @param nestId identifies the Nest thing
+ */
+ void onMissingData(String nestId);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.rest;
+
+import java.io.IOException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nest.internal.wwn.WWNBindingConstants;
+import org.openhab.binding.nest.internal.wwn.WWNUtils;
+import org.openhab.binding.nest.internal.wwn.config.WWNAccountConfiguration;
+import org.openhab.binding.nest.internal.wwn.dto.WWNAccessTokenData;
+import org.openhab.binding.nest.internal.wwn.exceptions.InvalidWWNAccessTokenException;
+import org.openhab.core.io.net.http.HttpUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Retrieves the WWN access token using the OAuth 2.0 protocol using pin-based authorization.
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Improve exception handling
+ */
+@NonNullByDefault
+public class WWNAuthorizer {
+ private final Logger logger = LoggerFactory.getLogger(WWNAuthorizer.class);
+
+ private final WWNAccountConfiguration config;
+
+ /**
+ * Create the helper class for the Nest access token. Also creates the folder
+ * to put the access token data in if it does not already exist.
+ *
+ * @param config The configuration to use for the token
+ */
+ public WWNAuthorizer(WWNAccountConfiguration config) {
+ this.config = config;
+ }
+
+ /**
+ * Get the current access token, refreshing if needed.
+ *
+ * @throws InvalidWWNAccessTokenException thrown when the access token is invalid and could not be refreshed
+ */
+ public String getNewAccessToken() throws InvalidWWNAccessTokenException {
+ try {
+ String pincode = config.pincode;
+ if (pincode == null || pincode.isBlank()) {
+ throw new InvalidWWNAccessTokenException("Pincode is empty");
+ }
+
+ StringBuilder urlBuilder = new StringBuilder(WWNBindingConstants.NEST_ACCESS_TOKEN_URL) //
+ .append("?client_id=") //
+ .append(config.productId) //
+ .append("&client_secret=") //
+ .append(config.productSecret) //
+ .append("&code=") //
+ .append(pincode) //
+ .append("&grant_type=authorization_code");
+
+ logger.debug("Requesting access token from URL: {}", urlBuilder);
+
+ String responseContentAsString = HttpUtil.executeUrl("POST", urlBuilder.toString(), null, null,
+ "application/x-www-form-urlencoded", 10_000);
+
+ WWNAccessTokenData data = WWNUtils.fromJson(responseContentAsString, WWNAccessTokenData.class);
+ logger.debug("Received: {}", data);
+
+ String accessToken = data.getAccessToken();
+ if (accessToken == null || accessToken.isBlank()) {
+ throw new InvalidWWNAccessTokenException("Pincode to obtain access token is already used or invalid)");
+ }
+ return accessToken;
+ } catch (IOException e) {
+ throw new InvalidWWNAccessTokenException("Access token request failed", e);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.rest;
+
+import java.io.IOException;
+
+import javax.ws.rs.client.ClientRequestContext;
+import javax.ws.rs.client.ClientRequestFilter;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Inserts Authorization and Cache-Control headers for requests on the streaming WWN REST API.
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Replace polling with REST streaming
+ */
+@NonNullByDefault
+public class WWNStreamingRequestFilter implements ClientRequestFilter {
+ private final String accessToken;
+
+ public WWNStreamingRequestFilter(String accessToken) {
+ this.accessToken = accessToken;
+ }
+
+ @Override
+ public void filter(@Nullable ClientRequestContext requestContext) throws IOException {
+ if (requestContext != null) {
+ MultivaluedMap<String, Object> headers = requestContext.getHeaders();
+ headers.putSingle(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
+ headers.putSingle(HttpHeaders.CACHE_CONTROL, "no-cache");
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.rest;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.KEEP_ALIVE_MILLIS;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.sse.InboundSseEvent;
+import javax.ws.rs.sse.SseEventSource;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.WWNUtils;
+import org.openhab.binding.nest.internal.wwn.dto.WWNTopLevelData;
+import org.openhab.binding.nest.internal.wwn.dto.WWNTopLevelStreamingData;
+import org.openhab.binding.nest.internal.wwn.exceptions.FailedResolvingWWNUrlException;
+import org.openhab.binding.nest.internal.wwn.handler.WWNRedirectUrlSupplier;
+import org.openhab.binding.nest.internal.wwn.listener.WWNStreamingDataListener;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A client that generates events based on Nest streaming WWN REST API Server-Sent Events (SSE).
+ *
+ * @author Wouter Born - Initial contribution
+ * @author Wouter Born - Replace polling with REST streaming
+ */
+@NonNullByDefault
+public class WWNStreamingRestClient {
+
+ // Assume connection timeout when 2 keep alive message should have been received
+ private static final long CONNECTION_TIMEOUT_MILLIS = 2 * KEEP_ALIVE_MILLIS + KEEP_ALIVE_MILLIS / 2;
+
+ public static final String AUTH_REVOKED = "auth_revoked";
+ public static final String ERROR = "error";
+ public static final String KEEP_ALIVE = "keep-alive";
+ public static final String OPEN = "open";
+ public static final String PUT = "put";
+
+ private final Logger logger = LoggerFactory.getLogger(WWNStreamingRestClient.class);
+
+ private final String accessToken;
+ private final ClientBuilder clientBuilder;
+ private final SseEventSourceFactory eventSourceFactory;
+ private final WWNRedirectUrlSupplier redirectUrlSupplier;
+ private final ScheduledExecutorService scheduler;
+
+ private final Object startStopLock = new Object();
+ private final List<WWNStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
+
+ private @Nullable ScheduledFuture<?> checkConnectionJob;
+ private boolean connected;
+ private @Nullable SseEventSource eventSource;
+ private long lastEventTimestamp;
+ private @Nullable WWNTopLevelData lastReceivedTopLevelData;
+
+ public WWNStreamingRestClient(String accessToken, ClientBuilder clientBuilder,
+ SseEventSourceFactory eventSourceFactory, WWNRedirectUrlSupplier redirectUrlSupplier,
+ ScheduledExecutorService scheduler) {
+ this.accessToken = accessToken;
+ this.clientBuilder = clientBuilder;
+ this.eventSourceFactory = eventSourceFactory;
+ this.redirectUrlSupplier = redirectUrlSupplier;
+ this.scheduler = scheduler;
+ }
+
+ private SseEventSource createEventSource() throws FailedResolvingWWNUrlException {
+ Client client = clientBuilder.register(new WWNStreamingRequestFilter(accessToken)).build();
+ SseEventSource eventSource = eventSourceFactory.newSource(client.target(redirectUrlSupplier.getRedirectUrl()));
+ eventSource.register(this::onEvent, this::onError);
+ return eventSource;
+ }
+
+ private void checkConnection() {
+ long millisSinceLastEvent = System.currentTimeMillis() - lastEventTimestamp;
+ if (millisSinceLastEvent > CONNECTION_TIMEOUT_MILLIS) {
+ logger.debug("Check: Disconnected from streaming events, millisSinceLastEvent={}", millisSinceLastEvent);
+ synchronized (startStopLock) {
+ stopCheckConnectionJob(false);
+ if (connected) {
+ connected = false;
+ listeners.forEach(listener -> listener.onDisconnected());
+ }
+ redirectUrlSupplier.resetCache();
+ reopenEventSource();
+ startCheckConnectionJob();
+ }
+ } else {
+ logger.debug("Check: Receiving streaming events, millisSinceLastEvent={}", millisSinceLastEvent);
+ }
+ }
+
+ /**
+ * Closes the existing EventSource and opens a new EventSource as workaround when the EventSource fails to reconnect
+ * itself.
+ */
+ private void reopenEventSource() {
+ try {
+ logger.debug("Reopening EventSource");
+ closeEventSource(10, TimeUnit.SECONDS);
+
+ logger.debug("Opening new EventSource");
+ SseEventSource localEventSource = createEventSource();
+ localEventSource.open();
+
+ eventSource = localEventSource;
+ } catch (FailedResolvingWWNUrlException e) {
+ logger.debug("Failed to resolve Nest redirect URL while opening new EventSource");
+ }
+ }
+
+ public void start() {
+ synchronized (startStopLock) {
+ logger.debug("Opening EventSource and starting checkConnection job");
+ reopenEventSource();
+ startCheckConnectionJob();
+ logger.debug("Started");
+ }
+ }
+
+ public void stop() {
+ synchronized (startStopLock) {
+ logger.debug("Closing EventSource and stopping checkConnection job");
+ stopCheckConnectionJob(true);
+ closeEventSource(0, TimeUnit.SECONDS);
+ logger.debug("Stopped");
+ }
+ }
+
+ private void closeEventSource(long timeout, TimeUnit timeoutUnit) {
+ SseEventSource localEventSource = eventSource;
+ if (localEventSource != null) {
+ if (!localEventSource.isOpen()) {
+ logger.debug("Existing EventSource is already closed");
+ } else if (localEventSource.close(timeout, timeoutUnit)) {
+ logger.debug("Succesfully closed existing EventSource");
+ } else {
+ logger.debug("Failed to close existing EventSource");
+ }
+ eventSource = null;
+ }
+ }
+
+ private void startCheckConnectionJob() {
+ ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
+ if (localCheckConnectionJob == null || localCheckConnectionJob.isCancelled()) {
+ checkConnectionJob = scheduler.scheduleWithFixedDelay(this::checkConnection, CONNECTION_TIMEOUT_MILLIS,
+ KEEP_ALIVE_MILLIS, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ private void stopCheckConnectionJob(boolean mayInterruptIfRunning) {
+ ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
+ if (localCheckConnectionJob != null && !localCheckConnectionJob.isCancelled()) {
+ localCheckConnectionJob.cancel(mayInterruptIfRunning);
+ checkConnectionJob = null;
+ }
+ }
+
+ public boolean addStreamingDataListener(WWNStreamingDataListener listener) {
+ return listeners.add(listener);
+ }
+
+ public boolean removeStreamingDataListener(WWNStreamingDataListener listener) {
+ return listeners.remove(listener);
+ }
+
+ public @Nullable WWNTopLevelData getLastReceivedTopLevelData() {
+ return lastReceivedTopLevelData;
+ }
+
+ private void onEvent(InboundSseEvent inboundEvent) {
+ try {
+ lastEventTimestamp = System.currentTimeMillis();
+
+ String name = inboundEvent.getName();
+ String data = inboundEvent.readData();
+
+ logger.debug("Received '{}' event, data: {}", name, data);
+
+ if (!connected) {
+ logger.debug("Connected to streaming events");
+ connected = true;
+ listeners.forEach(listener -> listener.onConnected());
+ }
+
+ if (AUTH_REVOKED.equals(name)) {
+ logger.debug("API authorization has been revoked for access token: {}", data);
+ listeners.forEach(listener -> listener.onAuthorizationRevoked(data));
+ } else if (ERROR.equals(name)) {
+ logger.warn("Error occurred: {}", data);
+ listeners.forEach(listener -> listener.onError(data));
+ } else if (KEEP_ALIVE.equals(name)) {
+ logger.debug("Received message to keep connection alive");
+ } else if (OPEN.equals(name)) {
+ logger.debug("Event stream opened");
+ } else if (PUT.equals(name)) {
+ logger.debug("Data has changed (or initial data sent)");
+ WWNTopLevelData topLevelData = WWNUtils.fromJson(data, WWNTopLevelStreamingData.class).getData();
+ lastReceivedTopLevelData = topLevelData;
+ listeners.forEach(listener -> listener.onNewTopLevelData(topLevelData));
+ } else {
+ logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
+ }
+ } catch (Exception e) {
+ // catch exceptions here otherwise they will be swallowed by the implementation
+ logger.warn("An exception occurred while processing the inbound event", e);
+ }
+ }
+
+ private void onError(Throwable error) {
+ logger.debug("Error occurred while receiving events", error);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.update;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNIdentifiable;
+import org.openhab.binding.nest.internal.wwn.dto.WWNTopLevelData;
+import org.openhab.binding.nest.internal.wwn.listener.WWNThingDataListener;
+
+/**
+ * Handles all Nest data updates through delegation to the {@link WWNUpdateHandler} for the respective data type.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WWNCompositeUpdateHandler {
+
+ private final Supplier<Set<String>> presentNestIdsSupplier;
+ private final Map<Class<?>, WWNUpdateHandler<?>> updateHandlersMap = new ConcurrentHashMap<>();
+
+ public WWNCompositeUpdateHandler(Supplier<Set<String>> presentNestIdsSupplier) {
+ this.presentNestIdsSupplier = presentNestIdsSupplier;
+ }
+
+ public <T> boolean addListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
+ return getOrCreateUpdateHandler(dataClass).addListener(listener);
+ }
+
+ public <T> boolean addListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
+ return getOrCreateUpdateHandler(dataClass).addListener(nestId, listener);
+ }
+
+ private Set<String> findMissingNestIds(Set<WWNIdentifiable> updates) {
+ Set<String> nestIds = updates.stream().map(u -> u.getId()).collect(Collectors.toSet());
+ Set<String> missingNestIds = presentNestIdsSupplier.get();
+ missingNestIds.removeAll(nestIds);
+ return missingNestIds;
+ }
+
+ public @Nullable <T> T getLastUpdate(Class<T> dataClass, String nestId) {
+ return getOrCreateUpdateHandler(dataClass).getLastUpdate(nestId);
+ }
+
+ public <T> List<T> getLastUpdates(Class<T> dataClass) {
+ return getOrCreateUpdateHandler(dataClass).getLastUpdates();
+ }
+
+ private Set<WWNIdentifiable> getNestUpdates(WWNTopLevelData data) {
+ Set<WWNIdentifiable> updates = new HashSet<>();
+ if (data.getDevices() != null) {
+ if (data.getDevices().getCameras() != null) {
+ updates.addAll(data.getDevices().getCameras().values());
+ }
+ if (data.getDevices().getSmokeCoAlarms() != null) {
+ updates.addAll(data.getDevices().getSmokeCoAlarms().values());
+ }
+ if (data.getDevices().getThermostats() != null) {
+ updates.addAll(data.getDevices().getThermostats().values());
+ }
+ }
+ if (data.getStructures() != null) {
+ updates.addAll(data.getStructures().values());
+ }
+ return updates;
+ }
+
+ @SuppressWarnings("unchecked")
+ private <@NonNull T> WWNUpdateHandler<T> getOrCreateUpdateHandler(Class<T> dataClass) {
+ WWNUpdateHandler<T> handler = (WWNUpdateHandler<T>) updateHandlersMap.get(dataClass);
+ if (handler == null) {
+ handler = new WWNUpdateHandler<>();
+ updateHandlersMap.put(dataClass, handler);
+ }
+ return handler;
+ }
+
+ @SuppressWarnings("unchecked")
+ public void handleUpdate(WWNTopLevelData data) {
+ Set<WWNIdentifiable> updates = getNestUpdates(data);
+ updates.forEach(update -> {
+ Class<WWNIdentifiable> updateClass = (Class<WWNIdentifiable>) update.getClass();
+ getOrCreateUpdateHandler(updateClass).handleUpdate(updateClass, update.getId(), update);
+ });
+
+ Set<String> missingNestIds = findMissingNestIds(updates);
+ if (!missingNestIds.isEmpty()) {
+ updateHandlersMap.values().forEach(handler -> {
+ handler.handleMissingNestIds(missingNestIds);
+ });
+ }
+ }
+
+ public <T> boolean removeListener(Class<T> dataClass, WWNThingDataListener<T> listener) {
+ return getOrCreateUpdateHandler(dataClass).removeListener(listener);
+ }
+
+ public <T> boolean removeListener(Class<T> dataClass, String nestId, WWNThingDataListener<T> listener) {
+ return getOrCreateUpdateHandler(dataClass).removeListener(nestId, listener);
+ }
+
+ public void resendLastUpdates() {
+ updateHandlersMap.values().forEach(handler -> {
+ handler.resendLastUpdates();
+ });
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.update;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.listener.WWNThingDataListener;
+
+/**
+ * Handles the updates of one type of data by notifying listeners of changes and storing the update value.
+ *
+ * @author Wouter Born - Initial contribution
+ *
+ * @param <T> the type of update data
+ */
+@NonNullByDefault
+public class WWNUpdateHandler<@NonNull T> {
+
+ /**
+ * The ID used for listeners that subscribe to any Nest update.
+ */
+ private static final String ANY_ID = "*";
+
+ private final Map<String, T> lastUpdates = new ConcurrentHashMap<>();
+ private final Map<String, Set<WWNThingDataListener<T>>> listenersMap = new ConcurrentHashMap<>();
+
+ public boolean addListener(WWNThingDataListener<T> listener) {
+ return addListener(ANY_ID, listener);
+ }
+
+ public boolean addListener(String nestId, WWNThingDataListener<T> listener) {
+ return getOrCreateListeners(nestId).add(listener);
+ }
+
+ public @Nullable T getLastUpdate(String nestId) {
+ return lastUpdates.get(nestId);
+ }
+
+ public List<T> getLastUpdates() {
+ return new ArrayList<>(lastUpdates.values());
+ }
+
+ private Set<WWNThingDataListener<T>> getListeners(String nestId) {
+ Set<WWNThingDataListener<T>> listeners = new HashSet<>();
+ Set<WWNThingDataListener<T>> idListeners = listenersMap.get(nestId);
+ if (idListeners != null) {
+ listeners.addAll(idListeners);
+ }
+ Set<WWNThingDataListener<T>> anyListeners = listenersMap.get(ANY_ID);
+ if (anyListeners != null) {
+ listeners.addAll(anyListeners);
+ }
+ return listeners;
+ }
+
+ private Set<WWNThingDataListener<T>> getOrCreateListeners(String nestId) {
+ Set<WWNThingDataListener<T>> listeners = listenersMap.get(nestId);
+ if (listeners == null) {
+ listeners = new CopyOnWriteArraySet<>();
+ listenersMap.put(nestId, listeners);
+ }
+ return listeners;
+ }
+
+ public void handleMissingNestIds(Set<String> nestIds) {
+ nestIds.forEach(nestId -> {
+ lastUpdates.remove(nestId);
+ getListeners(nestId).forEach(l -> l.onMissingData(nestId));
+ });
+ }
+
+ public void handleUpdate(Class<T> dataClass, String nestId, T update) {
+ final @Nullable T lastUpdate = getLastUpdate(nestId);
+ lastUpdates.put(nestId, update);
+ notifyListeners(nestId, lastUpdate, update);
+ }
+
+ private void notifyListeners(String nestId, @Nullable T lastUpdate, T update) {
+ Set<WWNThingDataListener<T>> listeners = getListeners(nestId);
+ if (lastUpdate == null) {
+ listeners.forEach(l -> l.onNewData(update));
+ } else if (!lastUpdate.equals(update)) {
+ listeners.forEach(l -> l.onUpdatedData(lastUpdate, update));
+ }
+ }
+
+ public boolean removeListener(WWNThingDataListener<T> listener) {
+ return removeListener(ANY_ID, listener);
+ }
+
+ public boolean removeListener(String nestId, WWNThingDataListener<T> listener) {
+ return getOrCreateListeners(nestId).remove(listener);
+ }
+
+ public void resendLastUpdates() {
+ lastUpdates.forEach((nestId, update) -> notifyListeners(nestId, null, update));
+ }
+}
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<config-description:config-descriptions
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
-
- <config-description uri="thing-type:nest:account">
- <parameter-group name="oauth">
- <label>Nest API OAuth</label>
- <description>The OAuth parameters used when communicating with the Nest API</description>
- </parameter-group>
- <parameter-group name="binding">
- <label>Binding Settings</label>
- <description>Local settings</description>
- </parameter-group>
-
- <parameter name="productId" type="text" groupName="oauth" required="true">
- <label>Product ID</label>
- <description>The product ID from the Nest product page</description>
- </parameter>
- <parameter name="productSecret" type="text" groupName="oauth" required="true">
- <label>Product Secret</label>
- <description>The product secret from the Nest product page</description>
- </parameter>
- <parameter name="pincode" type="text" groupName="oauth">
- <label>Pincode</label>
- <description>The single use pincode for obtaining an OAuth access token.
- Get the pincode by accepting to the terms
- shown at the product authorization URL.
- This value is automatically reset when the access token has been obtained</description>
- </parameter>
- <parameter name="accessToken" type="text" groupName="oauth">
- <label>Access Token</label>
- <description>The access token used for authenticating to the Nest API.
- It is automatically obtained from Nest when the
- value is empty and
- a valid pincode parameter is entered</description>
- <advanced>true</advanced>
- </parameter>
- </config-description>
-
- <config-description uri="thing-type:nest:device">
- <parameter name="deviceId" type="text" required="true">
- <label>Device ID</label>
- </parameter>
- </config-description>
-
- <config-description uri="thing-type:nest:structure">
- <parameter name="structureId" type="text" required="true">
- <label>Structure ID</label>
- </parameter>
- </config-description>
-
-</config-description:config-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+ <config-description uri="thing-type:nest:sdm_account">
+ <parameter-group name="sdm">
+ <label>SDM</label>
+ <description>The parameters used when communicating with the SDM API</description>
+ </parameter-group>
+ <parameter-group name="pubsub">
+ <label>Pub/Sub</label>
+ <description>The parameters used when communicating with the Pub/Sub API</description>
+ </parameter-group>
+
+ <parameter name="sdmProjectId" type="text" required="true" groupName="sdm">
+ <label>Project ID</label>
+ <description>The UUID that identifies the SDM project in the SDM "Device Access Console"</description>
+ </parameter>
+ <parameter name="sdmClientId" type="text" required="true" groupName="sdm">
+ <label>Client ID</label>
+ <description>Identifies the OAuth 2.0 client used for accessing the SDM project</description>
+ </parameter>
+ <parameter name="sdmClientSecret" type="text" required="true" groupName="sdm">
+ <context>password</context>
+ <label>Client Secret</label>
+ <description>The OAuth 2.0 client secret used for accessing the SDM project</description>
+ </parameter>
+ <parameter name="sdmAuthorizationCode" type="text" groupName="sdm">
+ <label>Authorization Code</label>
+ <description><![CDATA[The one time authorization code used to retrieve the refresh and access token used with the SDM API. The code is obtained by following the instructions at the following URL in your browser:<br><br>https://nestservices.google.com/partnerconnections/{{ProjectID}}/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&access_type=offline&prompt=consent&client_id={{ClientID}}&response_type=code&scope=https://www.googleapis.com/auth/sdm.service]]></description>
+ </parameter>
+
+ <parameter name="pubsubProjectId" type="text" groupName="pubsub">
+ <label>Project ID</label>
+ <description>Identifies the Google Cloud Platform project where the Pub/Sub subscription is created</description>
+ </parameter>
+ <parameter name="pubsubSubscriptionId" type="text" groupName="pubsub">
+ <label>Subscription ID</label>
+ <description>Identifies the subscription that is created for subscribing to SDM Pub/Sub events</description>
+ </parameter>
+ <parameter name="pubsubClientId" type="text" groupName="pubsub">
+ <label>Client ID</label>
+ <description>Identifies the OAuth 2.0 client used for accessing the Pub/Sub subscription</description>
+ </parameter>
+ <parameter name="pubsubClientSecret" type="text" groupName="pubsub">
+ <context>password</context>
+ <label>Client Secret</label>
+ <description>The OAuth 2.0 client secret used for accessing the Pub/Sub subscription</description>
+ </parameter>
+ <parameter name="pubsubAuthorizationCode" type="text" groupName="pubsub">
+ <label>Authorization Code</label>
+ <description><![CDATA[The one time authorization code used to retrieve the refresh and access token used with the Pub/Sub API. The code is obtained by following the instructions at the following URL in your browser:<br><br>https://accounts.google.com/o/oauth2/auth?client_id={{ClientID}}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/pubsub]]></description>
+ </parameter>
+ </config-description>
+
+ <config-description uri="thing-type:nest:sdm_device">
+ <parameter name="deviceId" type="text" required="true">
+ <label>Device ID</label>
+ </parameter>
+ <parameter name="refreshInterval" type="integer" min="30" step="1" unit="s">
+ <label>Refresh Interval</label>
+ <description>This is refresh interval in seconds to update the Nest device information</description>
+ <default>300</default>
+ <unitLabel>s</unitLabel>
+ </parameter>
+ </config-description>
+
+ <config-description uri="channel-type:nest:sdm_camera_image">
+ <parameter name="imageWidth" type="integer" min="1" step="1">
+ <label>Image Width</label>
+ <description>The width in pixels used for generating event images. A default value of 480 pixels is used if not
+ configured.</description>
+ <unitLabel>px</unitLabel>
+ </parameter>
+ <parameter name="imageHeight" type="integer" min="1" step="1">
+ <label>Image Height</label>
+ <description>The height in pixels used for generating event images. This parameter is ignored when the image width
+ parameter is also configured.</description>
+ <unitLabel>px</unitLabel>
+ </parameter>
+ </config-description>
+
+ <config-description uri="channel-type:nest:sdm_fan_timer_mode">
+ <parameter name="fanTimerDuration" type="integer" min="1" max="43200" step="1" unit="s">
+ <label>Fan Timer Duration</label>
+ <description>Specifies the length of time in seconds that the timer is set to run.</description>
+ <default>900</default>
+ <unitLabel>s</unitLabel>
+ </parameter>
+ </config-description>
+
+</config-description:config-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+ <config-description uri="thing-type:nest:wwn_account">
+ <parameter-group name="oauth">
+ <label>WWN API OAuth</label>
+ <description>The OAuth parameters used when communicating with the WWN API</description>
+ </parameter-group>
+
+ <parameter name="productId" type="text" groupName="oauth" required="true">
+ <label>Product ID</label>
+ <description>The product ID from the Nest product page</description>
+ </parameter>
+ <parameter name="productSecret" type="text" groupName="oauth" required="true">
+ <label>Product Secret</label>
+ <description>The product secret from the Nest product page</description>
+ </parameter>
+ <parameter name="accessToken" type="text" groupName="oauth" required="true">
+ <label>Access Token</label>
+ <description>The access token used for authenticating to the WWN API</description>
+ </parameter>
+ </config-description>
+
+ <config-description uri="thing-type:nest:wwn_device">
+ <parameter name="deviceId" type="text" required="true">
+ <label>Device ID</label>
+ </parameter>
+ </config-description>
+
+ <config-description uri="thing-type:nest:wwn_structure">
+ <parameter name="structureId" type="text" required="true">
+ <label>Structure ID</label>
+ </parameter>
+ </config-description>
+
+</config-description:config-descriptions>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="nest"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
- <bridge-type id="account">
- <label>Nest Account</label>
- <description>An account for using the Nest REST API</description>
- <config-description-ref uri="thing-type:nest:account"/>
- </bridge-type>
-</thing:thing-descriptions>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="nest"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
- <thing-type id="camera">
- <supported-bridge-type-refs>
- <bridge-type-ref id="account"/>
- </supported-bridge-type-refs>
-
- <label>Nest Cam</label>
- <description>A Nest Cam registered with your account</description>
-
- <channel-groups>
- <channel-group id="camera" typeId="Camera"/>
- <channel-group id="last_event" typeId="CameraEvent">
- <label>Last Event</label>
- <description>Information about the last camera event (requires Nest Aware subscription)</description>
- </channel-group>
- </channel-groups>
-
- <properties>
- <property name="vendor">Nest</property>
- </properties>
-
- <representation-property>deviceId</representation-property>
-
- <config-description-ref uri="thing-type:nest:device"/>
- </thing-type>
-</thing:thing-descriptions>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="nest"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
- <!-- Common -->
- <channel-type id="LastConnection" advanced="true">
- <item-type>DateTime</item-type>
- <label>Last Connection</label>
- <description>Timestamp of the last successful interaction with Nest</description>
- <state readOnly="true"/>
- </channel-type>
-
- <!-- Structure -->
- <channel-type id="Away">
- <item-type>String</item-type>
- <label>Away</label>
- <description>Away state of the structure</description>
- <state>
- <options>
- <option value="AWAY">Away</option>
- <option value="HOME">Home</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="CountryCode" advanced="true">
- <item-type>String</item-type>
- <label>Country Code</label>
- <description>Country code of the structure</description>
- </channel-type>
-
- <channel-type id="PostalCode" advanced="true">
- <item-type>String</item-type>
- <label>Postal Code</label>
- <description>Postal code of the structure</description>
- </channel-type>
-
- <channel-type id="TimeZone">
- <item-type>String</item-type>
- <label>Time Zone</label>
- <description>The time zone for the structure</description>
- </channel-type>
-
- <channel-type id="PeakPeriodStartTime" advanced="true">
- <item-type>DateTime</item-type>
- <label>Peak Period Start Time</label>
- <description>Peak period start for the Rush Hour Rewards program</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="PeakPeriodEndTime" advanced="true">
- <item-type>DateTime</item-type>
- <label>Peak Period End Time</label>
- <description>Peak period end for the Rush Hour Rewards program</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="EtaBegin" advanced="true">
- <item-type>DateTime</item-type>
- <label>ETA</label>
- <description>
- Estimated time of arrival at home, will setup the heat to turn on and be warm
- by the time you arrive
- </description>
- </channel-type>
-
- <channel-type id="RushHourRewardsEnrollment">
- <item-type>Switch</item-type>
- <label>Rush Hour Rewards</label>
- <description>If rush hour rewards system is enabled or not</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="SecurityState">
- <item-type>String</item-type>
- <label>Security State</label>
- <description>Security state of the structure</description>
- <state readOnly="true">
- <options>
- <option value="OK">ok</option>
- <option value="DETER">deter</option>
- </options>
- </state>
- </channel-type>
-
- <!-- Camera -->
- <channel-group-type id="Camera">
- <label>Camera</label>
- <description>Information about the camera</description>
- <channels>
- <channel id="streaming" typeId="Streaming"/>
- <channel id="audio_input_enabled" typeId="AudioInputEnabled"/>
- <channel id="public_share_enabled" typeId="PublicShareEnabled"/>
- <channel id="video_history_enabled" typeId="VideoHistoryEnabled"/>
- <channel id="app_url" typeId="AppUrl"/>
- <channel id="snapshot_url" typeId="SnapshotUrl"/>
- <channel id="public_share_url" typeId="PublicShareUrl"/>
- <channel id="web_url" typeId="WebUrl"/>
- <channel id="last_online_change" typeId="LastOnlineChange"/>
- </channels>
- </channel-group-type>
-
- <channel-type id="AudioInputEnabled" advanced="true">
- <item-type>Switch</item-type>
- <label>Audio Input Enabled</label>
- <description>If the audio input is enabled for this camera</description>
- </channel-type>
-
- <channel-type id="VideoHistoryEnabled" advanced="true">
- <item-type>Switch</item-type>
- <label>Video History Enabled</label>
- <description>If the video history is enabled for this camera</description>
- </channel-type>
-
- <channel-type id="PublicShareEnabled" advanced="true">
- <item-type>Switch</item-type>
- <label>Public Share Enabled</label>
- <description>If the public sharing of this camera is enabled</description>
- </channel-type>
-
- <channel-type id="Streaming">
- <item-type>Switch</item-type>
- <label>Streaming</label>
- <description>If the camera is currently streaming</description>
- </channel-type>
-
- <channel-type id="WebUrl">
- <item-type>String</item-type>
- <label>Web URL</label>
- <description>The web URL for the camera, allows you to see the camera in a web page</description>
- </channel-type>
-
- <channel-type id="PublicShareUrl">
- <item-type>String</item-type>
- <label>Public Share URL</label>
- <description>The publicly available URL for the camera</description>
- </channel-type>
-
- <channel-type id="SnapshotUrl" advanced="true">
- <item-type>String</item-type>
- <label>Snapshot URL</label>
- <description>The URL showing a snapshot of the camera</description>
- </channel-type>
-
- <channel-type id="AppUrl" advanced="true">
- <item-type>String</item-type>
- <label>App URL</label>
- <description>The app URL for the camera, allows you to see the camera in an app</description>
- </channel-type>
-
- <channel-type id="LastOnlineChange" advanced="true">
- <item-type>DateTime</item-type>
- <label>Last Online Change</label>
- <description>Timestamp of the last online status change</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-group-type id="CameraEvent">
- <label>Camera Event</label>
- <description>Information about the camera event</description>
- <channels>
- <channel id="has_motion" typeId="CameraEventHasMotion"/>
- <channel id="has_sound" typeId="CameraEventHasSound"/>
- <channel id="has_person" typeId="CameraEventHasPerson"/>
- <channel id="start_time" typeId="CameraEventStartTime"/>
- <channel id="end_time" typeId="CameraEventEndTime"/>
- <channel id="urls_expire_time" typeId="CameraEventUrlsExpireTime"/>
- <channel id="animated_image_url" typeId="CameraEventAnimatedImageUrl"/>
- <channel id="app_url" typeId="CameraEventAppUrl"/>
- <channel id="image_url" typeId="CameraEventImageUrl"/>
- <channel id="web_url" typeId="CameraEventWebUrl"/>
- <channel id="activity_zones" typeId="CameraEventActivityZones"/>
- </channels>
- </channel-group-type>
-
- <channel-type id="CameraEventHasSound" advanced="true">
- <item-type>Switch</item-type>
- <label>Has Sound</label>
- <description>If sound was detected in the camera event</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventHasMotion" advanced="true">
- <item-type>Switch</item-type>
- <label>Has Motion</label>
- <description>If motion was detected in the camera event</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventHasPerson" advanced="true">
- <item-type>Switch</item-type>
- <label>Has Person</label>
- <description>If a person was detected in the camera event</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventStartTime" advanced="true">
- <item-type>DateTime</item-type>
- <label>Start Time</label>
- <description>Timestamp when the camera event started</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventEndTime" advanced="true">
- <item-type>DateTime</item-type>
- <label>End Time</label>
- <description>Timestamp when the camera event ended</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventUrlsExpireTime" advanced="true">
- <item-type>DateTime</item-type>
- <label>URLs Expire Time</label>
- <description>Timestamp when the camera event URLs expire</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventWebUrl" advanced="true">
- <item-type>String</item-type>
- <label>Web URL</label>
- <description>The web URL for the camera event, allows you to see the camera event in a web page</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventAppUrl" advanced="true">
- <item-type>String</item-type>
- <label>App URL</label>
- <description>The app URL for the camera event, allows you to see the camera event in an app</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventImageUrl" advanced="true">
- <item-type>String</item-type>
- <label>Image URL</label>
- <description>The URL showing an image for the camera event</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventAnimatedImageUrl" advanced="true">
- <item-type>String</item-type>
- <label>Animated Image URL</label>
- <description>The URL showing an animated image for the camera event</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CameraEventActivityZones" advanced="true">
- <item-type>String</item-type>
- <label>Activity Zones</label>
- <description>Identifiers for activity zones that detected the event (comma separated)</description>
- <state readOnly="true"/>
- </channel-type>
-
- <!-- Smoke detector -->
- <channel-type id="UiColorState" advanced="true">
- <item-type>String</item-type>
- <label>UI Color State</label>
- <description>Current color state of the protect</description>
- <state readOnly="true">
- <options>
- <option value="GRAY">gray</option>
- <option value="GREEN">green</option>
- <option value="YELLOW">yellow</option>
- <option value="RED">red</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="CoAlarmState">
- <item-type>String</item-type>
- <label>CO Alarm State</label>
- <description>Carbon monoxide alarm state</description>
- <state readOnly="true">
- <options>
- <option value="OK">ok</option>
- <option value="EMERGENCY">emergency</option>
- <option value="WARNING">warning</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="SmokeAlarmState">
- <item-type>String</item-type>
- <label>Smoke Alarm State</label>
- <description>Smoke alarm state</description>
- <state readOnly="true">
- <options>
- <option value="OK">ok</option>
- <option value="EMERGENCY">emergency</option>
- <option value="WARNING">warning</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="ManualTestActive" advanced="true">
- <item-type>Switch</item-type>
- <label>Manual Test Active</label>
- <description>If the manual test is currently active</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="LastManualTestTime" advanced="true">
- <item-type>DateTime</item-type>
- <label>Last Manual Test Time</label>
- <description>Timestamp of the last successful manual test</description>
- <state readOnly="true"/>
- </channel-type>
-
- <!-- Thermostat -->
- <channel-type id="Temperature">
- <item-type>Number:Temperature</item-type>
- <label>Temperature</label>
- <description>Current temperature</description>
- <category>Temperature</category>
- <state readOnly="true" pattern="%.1f %unit%"/>
- </channel-type>
-
- <channel-type id="SetPoint">
- <item-type>Number:Temperature</item-type>
- <label>Set Point</label>
- <description>The set point temperature</description>
- <category>Temperature</category>
- <state pattern="%.1f %unit%" step="0.5"/>
- </channel-type>
-
- <channel-type id="MaxSetPoint">
- <item-type>Number:Temperature</item-type>
- <label>Max Set Point</label>
- <description>The max set point temperature</description>
- <category>Temperature</category>
- <state pattern="%.1f %unit%" step="0.5"/>
- </channel-type>
-
- <channel-type id="MinSetPoint">
- <item-type>Number:Temperature</item-type>
- <label>Min Set Point</label>
- <description>The min set point temperature</description>
- <category>Temperature</category>
- <state pattern="%.1f %unit%" step="0.5"/>
- </channel-type>
-
- <channel-type id="EcoMaxSetPoint" advanced="true">
- <item-type>Number:Temperature</item-type>
- <label>Eco Max Set Point</label>
- <description>The eco range max set point temperature</description>
- <category>Temperature</category>
- <state readOnly="true" pattern="%.1f %unit%"/>
- </channel-type>
-
- <channel-type id="EcoMinSetPoint" advanced="true">
- <item-type>Number:Temperature</item-type>
- <label>Eco Min Set Point</label>
- <description>The eco range min set point temperature</description>
- <category>Temperature</category>
- <state readOnly="true" pattern="%.1f %unit%"/>
- </channel-type>
-
- <channel-type id="LockedMaxSetPoint" advanced="true">
- <item-type>Number:Temperature</item-type>
- <label>Locked Max Set Point</label>
- <description>The locked range max set point temperature</description>
- <category>Temperature</category>
- <state readOnly="true" pattern="%.1f %unit%"/>
- </channel-type>
-
- <channel-type id="LockedMinSetPoint" advanced="true">
- <item-type>Number:Temperature</item-type>
- <label>Locked Min Set Point</label>
- <description>The locked range min set point temperature</description>
- <category>Temperature</category>
- <state readOnly="true" pattern="%.1f %unit%"/>
- </channel-type>
-
- <channel-type id="Locked" advanced="true">
- <item-type>Switch</item-type>
- <label>Locked</label>
- <description>If the thermostat has the temperature locked to only be within a set range</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="Mode">
- <item-type>String</item-type>
- <label>Mode</label>
- <description>Current mode of the Nest thermostat</description>
- <state>
- <options>
- <option value="OFF">off</option>
- <option value="ECO">eco</option>
- <option value="HEAT">heating</option>
- <option value="COOL">cooling</option>
- <option value="HEAT_COOL">heat/cool</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="PreviousMode" advanced="true">
- <item-type>String</item-type>
- <label>Previous Mode</label>
- <description>The previous mode of the Nest thermostat</description>
- <state readOnly="true">
- <options>
- <option value="OFF">off</option>
- <option value="ECO">eco</option>
- <option value="HEAT">heating</option>
- <option value="COOL">cooling</option>
- <option value="HEAT_COOL">heat/cool</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="State" advanced="true">
- <item-type>String</item-type>
- <label>State</label>
- <description>The active state of the Nest thermostat</description>
- <state readOnly="true">
- <options>
- <option value="OFF">off</option>
- <option value="HEATING">heating</option>
- <option value="COOLING">cooling</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="Humidity">
- <item-type>Number:Dimensionless</item-type>
- <label>Humidity</label>
- <description>Indicates the current relative humidity</description>
- <category>Humidity</category>
- <state pattern="%.1f %unit%" readOnly="true"/>
- </channel-type>
-
- <channel-type id="TimeToTarget">
- <item-type>Number:Time</item-type>
- <label>Time to Target</label>
- <description>Time left to the target temperature approximately</description>
- <state pattern="%d %unit%" readOnly="true"/>
- </channel-type>
-
- <channel-type id="CanHeat" advanced="true">
- <item-type>Switch</item-type>
- <label>Can Heat</label>
- <description>If the thermostat can actually turn on heating</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="CanCool" advanced="true">
- <item-type>Switch</item-type>
- <label>Can Cool</label>
- <description>If the thermostat can actually turn on cooling</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="FanTimerActive" advanced="true">
- <item-type>Switch</item-type>
- <label>Fan Timer Active</label>
- <description>If the fan timer is engaged</description>
- <state/>
- </channel-type>
-
- <channel-type id="FanTimerDuration" advanced="true">
- <item-type>Number:Time</item-type>
- <label>Fan Timer Duration</label>
- <description>Length of time that the fan is set to run</description>
- <state>
- <options>
- <option value="15">15 min</option>
- <option value="30">30 min</option>
- <option value="45">45 min</option>
- <option value="60">1 h</option>
- <option value="120">2 h</option>
- <option value="240">4 h</option>
- <option value="480">8 h</option>
- <option value="960">16 h</option>
- </options>
- </state>
- </channel-type>
-
- <channel-type id="FanTimerTimeout" advanced="true">
- <item-type>DateTime</item-type>
- <label>Fan Timer Timeout</label>
- <description>Timestamp when the fan stops running</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="HasFan" advanced="true">
- <item-type>Switch</item-type>
- <label>Has Fan</label>
- <description>If the thermostat can control the fan</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="HasLeaf" advanced="true">
- <item-type>Switch</item-type>
- <label>Has Leaf</label>
- <description>If the thermostat is currently in a leaf mode</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="SunlightCorrectionEnabled" advanced="true">
- <item-type>Switch</item-type>
- <label>Sunlight Correction Enabled</label>
- <description>If sunlight correction is enabled</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="SunlightCorrectionActive" advanced="true">
- <item-type>Switch</item-type>
- <label>Sunlight Correction Active</label>
- <description>If sunlight correction is active</description>
- <state readOnly="true"/>
- </channel-type>
-
- <channel-type id="UsingEmergencyHeat" advanced="true">
- <item-type>Switch</item-type>
- <label>Using Emergency Heat</label>
- <description>If the system is currently using emergency heat</description>
- <state readOnly="true"/>
- </channel-type>
-</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <bridge-type id="sdm_account">
+ <label>Nest SDM Account</label>
+ <description>An account for using the Smart Device Management (SDM) API</description>
+ <config-description-ref uri="thing-type:nest:sdm_account"/>
+ </bridge-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="sdm_camera" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="sdm_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Camera</label>
+ <description>A Nest Camera registered with your SDM account</description>
+
+ <channel-groups>
+ <channel-group id="motion_event" typeId="SDMMotionEvent"/>
+ <channel-group id="person_event" typeId="SDMPersonEvent"/>
+ <channel-group id="sound_event" typeId="SDMSoundEvent"/>
+ <channel-group id="live_stream" typeId="SDMLiveStream"/>
+ </channel-groups>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:sdm_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <!-- Camera -->
+ <channel-group-type id="SDMChimeEvent">
+ <label>Chime Event</label>
+ <description>Information about the last chime event</description>
+ <channels>
+ <channel id="image" typeId="SDMCameraEventImage">
+ <label>Chime Event Image</label>
+ <description>Static image based on a chime event</description>
+ </channel>
+ <channel id="timestamp" typeId="SDMCameraEventTimestamp">
+ <label>Chime Event Timestamp</label>
+ <description>The last time that the door chime was pressed</description>
+ </channel>
+ </channels>
+ </channel-group-type>
+
+ <channel-group-type id="SDMMotionEvent">
+ <label>Motion Event</label>
+ <description>Information about the last motion event</description>
+ <channels>
+ <channel id="image" typeId="SDMCameraEventImage">
+ <label>Motion Event Image</label>
+ <description>Static image based on a motion event</description>
+ </channel>
+ <channel id="timestamp" typeId="SDMCameraEventTimestamp">
+ <label>Motion Event Timestamp</label>
+ <description>The last time that motion was detected</description>
+ </channel>
+ </channels>
+ </channel-group-type>
+
+ <channel-group-type id="SDMPersonEvent">
+ <label>Person Event</label>
+ <description>Information about the last person event</description>
+ <channels>
+ <channel id="image" typeId="SDMCameraEventImage">
+ <label>Person Event Image</label>
+ <description>Static image based on a person event</description>
+ </channel>
+ <channel id="timestamp" typeId="SDMCameraEventTimestamp">
+ <label>Person Event Timestamp</label>
+ <description>The last time that a person was detected</description>
+ </channel>
+ </channels>
+ </channel-group-type>
+
+ <channel-group-type id="SDMSoundEvent">
+ <label>Sound Event</label>
+ <description>Information about the last sound event</description>
+ <channels>
+ <channel id="image" typeId="SDMCameraEventImage">
+ <label>Sound Event Image</label>
+ <description>Static image based on a sound event</description>
+ </channel>
+ <channel id="timestamp" typeId="SDMCameraEventTimestamp">
+ <label>Sound Event Timestamp</label>
+ <description>The last time that a sound was detected</description>
+ </channel>
+ </channels>
+ </channel-group-type>
+
+ <channel-type id="SDMCameraEventImage">
+ <item-type>Image</item-type>
+ <label>Image</label>
+ <description>Static image based on a event</description>
+ <state readOnly="true"/>
+ <config-description-ref uri="channel-type:nest:sdm_camera_image"/>
+ </channel-type>
+
+ <channel-type id="SDMCameraEventTimestamp">
+ <item-type>DateTime</item-type>
+ <label>Timestamp</label>
+ <description>The time that the event occurred</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-group-type id="SDMLiveStream">
+ <label>Live Stream</label>
+ <description>Information for accessing the live stream</description>
+ <channels>
+ <channel id="url" typeId="SDMLiveStreamUrl"/>
+ <channel id="expiration_timestamp" typeId="SDMLiveStreamExpirationTimestamp"/>
+ <channel id="current_token" typeId="SDMLiveStreamCurrentToken"/>
+ <channel id="extension_token" typeId="SDMLiveStreamExtensionToken"/>
+ </channels>
+ </channel-group-type>
+
+ <channel-type id="SDMLiveStreamUrl">
+ <item-type>String</item-type>
+ <label>Live Stream URL</label>
+ <description>The RTSP video stream URL for the most recent event</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="SDMLiveStreamExpirationTimestamp">
+ <item-type>DateTime</item-type>
+ <label>Live Stream Expiration Timestamp</label>
+ <description>Live stream token expiration time</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="SDMLiveStreamCurrentToken">
+ <item-type>String</item-type>
+ <label>Live Stream Current Token</label>
+ <description>Live stream current token value</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="SDMLiveStreamExtensionToken">
+ <item-type>String</item-type>
+ <label>Live Stream Extension Token</label>
+ <description>Live stream token extension value</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <!-- Thermostat -->
+ <channel-type id="SDMAmbientHumidity">
+ <item-type>Number:Dimensionless</item-type>
+ <label>Ambient Humidity</label>
+ <description>Lists the current ambient humidity percentage from the thermostat</description>
+ <category>Humidity</category>
+ <state pattern="%.1f %unit%" readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="SDMAmbientTemperature">
+ <item-type>Number:Temperature</item-type>
+ <label>Ambient Temperature</label>
+ <description>Lists the current ambient temperature from the thermostat</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="SDMCurrentEcoMode">
+ <item-type>String</item-type>
+ <label>Current Eco Mode</label>
+ <description>Lists the current eco mode from the thermostat</description>
+ <state>
+ <options>
+ <option value="OFF">off</option>
+ <option value="MANUAL_ECO">manual eco</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="SDMCurrentMode">
+ <item-type>String</item-type>
+ <label>Current Mode</label>
+ <description>Lists the current mode from the thermostat</description>
+ <state>
+ <options>
+ <option value="OFF">off</option>
+ <option value="HEAT">heating</option>
+ <option value="COOL">cooling</option>
+ <option value="HEATCOOL">heat/cool</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="SDMFanTimerMode">
+ <item-type>Switch</item-type>
+ <label>Fan Timer Mode</label>
+ <description>Lists the current fan timer mode</description>
+ <config-description-ref uri="channel-type:nest:sdm_fan_timer_mode"/>
+ </channel-type>
+
+ <channel-type id="SDMFanTimerTimeout">
+ <item-type>DateTime</item-type>
+ <label>Fan Timer Timeout</label>
+ <description>Timestamp at which timer mode turns OFF</description>
+ </channel-type>
+
+ <channel-type id="SDMHVACStatus">
+ <item-type>String</item-type>
+ <label>HVAC Status</label>
+ <description>Provides the thermostat HVAC Status</description>
+ <state readOnly="true">
+ <options>
+ <option value="OFF">off</option>
+ <option value="HEATING">heating</option>
+ <option value="COOLING">cooling</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="SDMMaximumTemperature">
+ <item-type>Number:Temperature</item-type>
+ <label>Maximum Temperature Setting</label>
+ <description>Lists the maximum temperature setting from the thermostat</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" step="0.5"/>
+ </channel-type>
+
+ <channel-type id="SDMMinimumTemperature">
+ <item-type>Number:Temperature</item-type>
+ <label>Minimum Temperature Setting</label>
+ <description>Lists the minimum temperature setting from the thermostat</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" step="0.5"/>
+ </channel-type>
+
+ <channel-type id="SDMTargetTemperature">
+ <item-type>Number:Temperature</item-type>
+ <label>Target Temperature</label>
+ <description>Lists the target temperature setting from the thermostat</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" step="0.5"/>
+ </channel-type>
+
+ <channel-type id="SDMTemperatureCool">
+ <item-type>Number:Temperature</item-type>
+ <label>Cool Temperature</label>
+ <description>Lists the cool temperature setting from the thermostat</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="SDMTemperatureHeat">
+ <item-type>Number:Temperature</item-type>
+ <label>Heat Temperature</label>
+ <description>Lists the heat temperature setting from the thermostat</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" readOnly="true"/>
+ </channel-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="sdm_display" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="sdm_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Display</label>
+ <description>A Nest Display registered with your SDM account</description>
+
+ <channel-groups>
+ <channel-group id="motion_event" typeId="SDMMotionEvent"/>
+ <channel-group id="person_event" typeId="SDMPersonEvent"/>
+ <channel-group id="sound_event" typeId="SDMSoundEvent"/>
+ <channel-group id="live_stream" typeId="SDMLiveStream"/>
+ </channel-groups>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:sdm_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="sdm_doorbell" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="sdm_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Doorbell</label>
+ <description>A Nest Doorbell registered with your SDM account</description>
+
+ <channel-groups>
+ <channel-group id="chime_event" typeId="SDMChimeEvent"/>
+ <channel-group id="motion_event" typeId="SDMMotionEvent"/>
+ <channel-group id="person_event" typeId="SDMPersonEvent"/>
+ <channel-group id="sound_event" typeId="SDMSoundEvent"/>
+ <channel-group id="live_stream" typeId="SDMLiveStream"/>
+ </channel-groups>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:sdm_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="sdm_thermostat" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="sdm_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Thermostat</label>
+ <description>A Thermostat to control the various aspects of the house's HVAC system</description>
+
+ <channels>
+ <channel id="ambient_humidity" typeId="SDMAmbientHumidity"/>
+ <channel id="ambient_temperature" typeId="SDMAmbientTemperature"/>
+ <channel id="fan_timer_mode" typeId="SDMFanTimerMode"/>
+ <channel id="fan_timer_timeout" typeId="SDMFanTimerTimeout"/>
+ <channel id="temperature_heat" typeId="SDMTemperatureHeat"/>
+ <channel id="temperature_cool" typeId="SDMTemperatureCool"/>
+ <channel id="current_mode" typeId="SDMCurrentMode"/>
+ <channel id="current_eco_mode" typeId="SDMCurrentEcoMode"/>
+ <channel id="target_temperature" typeId="SDMTargetTemperature"/>
+ <channel id="minimum_temperature" typeId="SDMMinimumTemperature"/>
+ <channel id="maximum_temperature" typeId="SDMMaximumTemperature"/>
+ <channel id="hvac_status" typeId="SDMHVACStatus"/>
+ </channels>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:sdm_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="nest"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
- <thing-type id="smoke_detector">
- <supported-bridge-type-refs>
- <bridge-type-ref id="account"/>
- </supported-bridge-type-refs>
-
- <label>Nest Protect</label>
- <description>The smoke detector/Nest Protect for the account</description>
-
- <channels>
- <channel id="ui_color_state" typeId="UiColorState"/>
- <channel id="low_battery" typeId="system.low-battery"/>
- <channel id="co_alarm_state" typeId="CoAlarmState"/>
- <channel id="smoke_alarm_state" typeId="SmokeAlarmState"/>
- <channel id="manual_test_active" typeId="ManualTestActive"/>
- <channel id="last_manual_test_time" typeId="LastManualTestTime"/>
- <channel id="last_connection" typeId="LastConnection"/>
- </channels>
-
- <properties>
- <property name="vendor">Nest</property>
- </properties>
-
- <representation-property>deviceId</representation-property>
-
- <config-description-ref uri="thing-type:nest:device"/>
- </thing-type>
-</thing:thing-descriptions>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="nest"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
- <thing-type id="structure">
- <supported-bridge-type-refs>
- <bridge-type-ref id="account"/>
- </supported-bridge-type-refs>
-
- <label>Nest Structure</label>
- <description>The Nest structure defines the house the account has setup on Nest.
- You will only have more than one
- structure if you have more than one house</description>
-
- <channels>
- <channel id="country_code" typeId="CountryCode"/>
- <channel id="postal_code" typeId="PostalCode"/>
- <channel id="time_zone" typeId="TimeZone"/>
- <channel id="peak_period_start_time" typeId="PeakPeriodStartTime"/>
- <channel id="peak_period_end_time" typeId="PeakPeriodEndTime"/>
- <channel id="rush_hour_rewards_enrollment" typeId="RushHourRewardsEnrollment"/>
- <channel id="eta_begin" typeId="EtaBegin"/>
- <channel id="co_alarm_state" typeId="CoAlarmState"/>
- <channel id="smoke_alarm_state" typeId="SmokeAlarmState"/>
- <channel id="security_state" typeId="SecurityState"/>
- <channel id="away" typeId="Away"/>
- </channels>
-
- <properties>
- <property name="vendor">Nest</property>
- </properties>
-
- <representation-property>structureId</representation-property>
-
- <config-description-ref uri="thing-type:nest:structure"/>
- </thing-type>
-
-</thing:thing-descriptions>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="nest"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
- xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
- <thing-type id="thermostat">
- <supported-bridge-type-refs>
- <bridge-type-ref id="account"/>
- </supported-bridge-type-refs>
-
- <label>Nest Thermostat</label>
- <description>A Thermostat to control the various aspects of the house's HVAC system</description>
-
- <channels>
- <channel id="temperature" typeId="Temperature"/>
- <channel id="humidity" typeId="Humidity"/>
- <channel id="mode" typeId="Mode"/>
- <channel id="previous_mode" typeId="PreviousMode"/>
- <channel id="state" typeId="State"/>
- <channel id="set_point" typeId="SetPoint"/>
- <channel id="max_set_point" typeId="MaxSetPoint"/>
- <channel id="min_set_point" typeId="MinSetPoint"/>
- <channel id="can_heat" typeId="CanHeat"/>
- <channel id="can_cool" typeId="CanCool"/>
- <channel id="fan_timer_active" typeId="FanTimerActive"/>
- <channel id="fan_timer_duration" typeId="FanTimerDuration"/>
- <channel id="fan_timer_timeout" typeId="FanTimerTimeout"/>
- <channel id="has_fan" typeId="HasFan"/>
- <channel id="has_leaf" typeId="HasLeaf"/>
- <channel id="sunlight_correction_enabled" typeId="SunlightCorrectionEnabled"/>
- <channel id="sunlight_correction_active" typeId="SunlightCorrectionActive"/>
- <channel id="using_emergency_heat" typeId="UsingEmergencyHeat"/>
- <channel id="eco_max_set_point" typeId="EcoMaxSetPoint"/>
- <channel id="eco_min_set_point" typeId="EcoMinSetPoint"/>
- <channel id="locked" typeId="Locked"/>
- <channel id="locked_max_set_point" typeId="LockedMaxSetPoint"/>
- <channel id="locked_min_set_point" typeId="LockedMinSetPoint"/>
- <channel id="time_to_target" typeId="TimeToTarget"/>
- <channel id="last_connection" typeId="LastConnection"/>
- </channels>
-
- <properties>
- <property name="vendor">Nest</property>
- </properties>
-
- <representation-property>deviceId</representation-property>
-
- <config-description-ref uri="thing-type:nest:device"/>
- </thing-type>
-</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <bridge-type id="wwn_account">
+ <label>Nest WWN Account</label>
+ <description>An account for using the Works with Nest (WWN) API</description>
+ <config-description-ref uri="thing-type:nest:wwn_account"/>
+ </bridge-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="wwn_camera" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="wwn_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Cam</label>
+ <description>A Nest Camera registered with your WWN account</description>
+
+ <channel-groups>
+ <channel-group id="camera" typeId="WWNCamera"/>
+ <channel-group id="last_event" typeId="WWNCameraEvent">
+ <label>Last Event</label>
+ <description>Information about the last camera event (requires Nest Aware subscription)</description>
+ </channel-group>
+ </channel-groups>
+
+ <properties>
+ <property name="vendor">Nest</property>
+ </properties>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:wwn_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <!-- Common -->
+ <channel-type id="WWNLastConnection" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Last Connection</label>
+ <description>Timestamp of the last successful interaction with Nest</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <!-- Structure -->
+ <channel-type id="WWNAway">
+ <item-type>String</item-type>
+ <label>Away</label>
+ <description>Away state of the structure</description>
+ <state>
+ <options>
+ <option value="AWAY">Away</option>
+ <option value="HOME">Home</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNCountryCode" advanced="true">
+ <item-type>String</item-type>
+ <label>Country Code</label>
+ <description>Country code of the structure</description>
+ </channel-type>
+
+ <channel-type id="WWNPostalCode" advanced="true">
+ <item-type>String</item-type>
+ <label>Postal Code</label>
+ <description>Postal code of the structure</description>
+ </channel-type>
+
+ <channel-type id="WWNTimeZone">
+ <item-type>String</item-type>
+ <label>Time Zone</label>
+ <description>The time zone for the structure</description>
+ </channel-type>
+
+ <channel-type id="WWNPeakPeriodStartTime" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Peak Period Start Time</label>
+ <description>Peak period start for the Rush Hour Rewards program</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNPeakPeriodEndTime" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Peak Period End Time</label>
+ <description>Peak period end for the Rush Hour Rewards program</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNEtaBegin" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>ETA</label>
+ <description>
+ Estimated time of arrival at home, will setup the heat to turn on and be warm
+ by the time you arrive
+ </description>
+ </channel-type>
+
+ <channel-type id="WWNRushHourRewardsEnrollment">
+ <item-type>Switch</item-type>
+ <label>Rush Hour Rewards</label>
+ <description>If rush hour rewards system is enabled or not</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNSecurityState">
+ <item-type>String</item-type>
+ <label>Security State</label>
+ <description>Security state of the structure</description>
+ <state readOnly="true">
+ <options>
+ <option value="OK">ok</option>
+ <option value="DETER">deter</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <!-- Camera -->
+ <channel-group-type id="WWNCamera">
+ <label>Camera</label>
+ <description>Information about the camera</description>
+ <channels>
+ <channel id="streaming" typeId="WWNStreaming"/>
+ <channel id="audio_input_enabled" typeId="WWNAudioInputEnabled"/>
+ <channel id="public_share_enabled" typeId="WWNPublicShareEnabled"/>
+ <channel id="video_history_enabled" typeId="WWNVideoHistoryEnabled"/>
+ <channel id="app_url" typeId="WWNAppUrl"/>
+ <channel id="snapshot_url" typeId="WWNSnapshotUrl"/>
+ <channel id="public_share_url" typeId="WWNPublicShareUrl"/>
+ <channel id="web_url" typeId="WWNWebUrl"/>
+ <channel id="last_online_change" typeId="WWNLastOnlineChange"/>
+ </channels>
+ </channel-group-type>
+
+ <channel-type id="WWNAudioInputEnabled" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Audio Input Enabled</label>
+ <description>If the audio input is enabled for this camera</description>
+ </channel-type>
+
+ <channel-type id="WWNVideoHistoryEnabled" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Video History Enabled</label>
+ <description>If the video history is enabled for this camera</description>
+ </channel-type>
+
+ <channel-type id="WWNPublicShareEnabled" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Public Share Enabled</label>
+ <description>If the public sharing of this camera is enabled</description>
+ </channel-type>
+
+ <channel-type id="WWNStreaming">
+ <item-type>Switch</item-type>
+ <label>Streaming</label>
+ <description>If the camera is currently streaming</description>
+ </channel-type>
+
+ <channel-type id="WWNWebUrl">
+ <item-type>String</item-type>
+ <label>Web URL</label>
+ <description>The web URL for the camera, allows you to see the camera in a web page</description>
+ </channel-type>
+
+ <channel-type id="WWNPublicShareUrl">
+ <item-type>String</item-type>
+ <label>Public Share URL</label>
+ <description>The publicly available URL for the camera</description>
+ </channel-type>
+
+ <channel-type id="WWNSnapshotUrl" advanced="true">
+ <item-type>String</item-type>
+ <label>Snapshot URL</label>
+ <description>The URL showing a snapshot of the camera</description>
+ </channel-type>
+
+ <channel-type id="WWNAppUrl" advanced="true">
+ <item-type>String</item-type>
+ <label>App URL</label>
+ <description>The app URL for the camera, allows you to see the camera in an app</description>
+ </channel-type>
+
+ <channel-type id="WWNLastOnlineChange" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Last Online Change</label>
+ <description>Timestamp of the last online status change</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-group-type id="WWNCameraEvent">
+ <label>Camera Event</label>
+ <description>Information about the camera event</description>
+ <channels>
+ <channel id="has_motion" typeId="WWNCameraEventHasMotion"/>
+ <channel id="has_sound" typeId="WWNCameraEventHasSound"/>
+ <channel id="has_person" typeId="WWNCameraEventHasPerson"/>
+ <channel id="start_time" typeId="WWNCameraEventStartTime"/>
+ <channel id="end_time" typeId="WWNCameraEventEndTime"/>
+ <channel id="urls_expire_time" typeId="WWNCameraEventUrlsExpireTime"/>
+ <channel id="animated_image_url" typeId="WWNCameraEventAnimatedImageUrl"/>
+ <channel id="app_url" typeId="WWNCameraEventAppUrl"/>
+ <channel id="image_url" typeId="WWNCameraEventImageUrl"/>
+ <channel id="web_url" typeId="WWNCameraEventWebUrl"/>
+ <channel id="activity_zones" typeId="WWNCameraEventActivityZones"/>
+ </channels>
+ </channel-group-type>
+
+ <channel-type id="WWNCameraEventHasSound" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Has Sound</label>
+ <description>If sound was detected in the camera event</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventHasMotion" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Has Motion</label>
+ <description>If motion was detected in the camera event</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventHasPerson" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Has Person</label>
+ <description>If a person was detected in the camera event</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventStartTime" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Start Time</label>
+ <description>Timestamp when the camera event started</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventEndTime" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>End Time</label>
+ <description>Timestamp when the camera event ended</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventUrlsExpireTime" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>URLs Expire Time</label>
+ <description>Timestamp when the camera event URLs expire</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventWebUrl" advanced="true">
+ <item-type>String</item-type>
+ <label>Web URL</label>
+ <description>The web URL for the camera event, allows you to see the camera event in a web page</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventAppUrl" advanced="true">
+ <item-type>String</item-type>
+ <label>App URL</label>
+ <description>The app URL for the camera event, allows you to see the camera event in an app</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventImageUrl" advanced="true">
+ <item-type>String</item-type>
+ <label>Image URL</label>
+ <description>The URL showing an image for the camera event</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventAnimatedImageUrl" advanced="true">
+ <item-type>String</item-type>
+ <label>Animated Image URL</label>
+ <description>The URL showing an animated image for the camera event</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCameraEventActivityZones" advanced="true">
+ <item-type>String</item-type>
+ <label>Activity Zones</label>
+ <description>Identifiers for activity zones that detected the event (comma separated)</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <!-- Smoke detector -->
+ <channel-type id="WWNUiColorState" advanced="true">
+ <item-type>String</item-type>
+ <label>UI Color State</label>
+ <description>Current color state of the protect</description>
+ <state readOnly="true">
+ <options>
+ <option value="GRAY">gray</option>
+ <option value="GREEN">green</option>
+ <option value="YELLOW">yellow</option>
+ <option value="RED">red</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNCoAlarmState">
+ <item-type>String</item-type>
+ <label>CO Alarm State</label>
+ <description>Carbon monoxide alarm state</description>
+ <state readOnly="true">
+ <options>
+ <option value="OK">ok</option>
+ <option value="EMERGENCY">emergency</option>
+ <option value="WARNING">warning</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNSmokeAlarmState">
+ <item-type>String</item-type>
+ <label>Smoke Alarm State</label>
+ <description>Smoke alarm state</description>
+ <state readOnly="true">
+ <options>
+ <option value="OK">ok</option>
+ <option value="EMERGENCY">emergency</option>
+ <option value="WARNING">warning</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNManualTestActive" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Manual Test Active</label>
+ <description>If the manual test is currently active</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNLastManualTestTime" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Last Manual Test Time</label>
+ <description>Timestamp of the last successful manual test</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <!-- Thermostat -->
+ <channel-type id="WWNTemperature">
+ <item-type>Number:Temperature</item-type>
+ <label>Temperature</label>
+ <description>Current temperature</description>
+ <category>Temperature</category>
+ <state readOnly="true" pattern="%.1f %unit%"/>
+ </channel-type>
+
+ <channel-type id="WWNSetPoint">
+ <item-type>Number:Temperature</item-type>
+ <label>Set Point</label>
+ <description>The set point temperature</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" step="0.5"/>
+ </channel-type>
+
+ <channel-type id="WWNMaxSetPoint">
+ <item-type>Number:Temperature</item-type>
+ <label>Max Set Point</label>
+ <description>The max set point temperature</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" step="0.5"/>
+ </channel-type>
+
+ <channel-type id="WWNMinSetPoint">
+ <item-type>Number:Temperature</item-type>
+ <label>Min Set Point</label>
+ <description>The min set point temperature</description>
+ <category>Temperature</category>
+ <state pattern="%.1f %unit%" step="0.5"/>
+ </channel-type>
+
+ <channel-type id="WWNEcoMaxSetPoint" advanced="true">
+ <item-type>Number:Temperature</item-type>
+ <label>Eco Max Set Point</label>
+ <description>The eco range max set point temperature</description>
+ <category>Temperature</category>
+ <state readOnly="true" pattern="%.1f %unit%"/>
+ </channel-type>
+
+ <channel-type id="WWNEcoMinSetPoint" advanced="true">
+ <item-type>Number:Temperature</item-type>
+ <label>Eco Min Set Point</label>
+ <description>The eco range min set point temperature</description>
+ <category>Temperature</category>
+ <state readOnly="true" pattern="%.1f %unit%"/>
+ </channel-type>
+
+ <channel-type id="WWNLockedMaxSetPoint" advanced="true">
+ <item-type>Number:Temperature</item-type>
+ <label>Locked Max Set Point</label>
+ <description>The locked range max set point temperature</description>
+ <category>Temperature</category>
+ <state readOnly="true" pattern="%.1f %unit%"/>
+ </channel-type>
+
+ <channel-type id="WWNLockedMinSetPoint" advanced="true">
+ <item-type>Number:Temperature</item-type>
+ <label>Locked Min Set Point</label>
+ <description>The locked range min set point temperature</description>
+ <category>Temperature</category>
+ <state readOnly="true" pattern="%.1f %unit%"/>
+ </channel-type>
+
+ <channel-type id="WWNLocked" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Locked</label>
+ <description>If the thermostat has the temperature locked to only be within a set range</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNMode">
+ <item-type>String</item-type>
+ <label>Mode</label>
+ <description>Current mode of the Nest thermostat</description>
+ <state>
+ <options>
+ <option value="OFF">off</option>
+ <option value="ECO">eco</option>
+ <option value="HEAT">heating</option>
+ <option value="COOL">cooling</option>
+ <option value="HEAT_COOL">heat/cool</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNPreviousMode" advanced="true">
+ <item-type>String</item-type>
+ <label>Previous Mode</label>
+ <description>The previous mode of the Nest thermostat</description>
+ <state readOnly="true">
+ <options>
+ <option value="OFF">off</option>
+ <option value="ECO">eco</option>
+ <option value="HEAT">heating</option>
+ <option value="COOL">cooling</option>
+ <option value="HEAT_COOL">heat/cool</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNState" advanced="true">
+ <item-type>String</item-type>
+ <label>State</label>
+ <description>The active state of the Nest thermostat</description>
+ <state readOnly="true">
+ <options>
+ <option value="OFF">off</option>
+ <option value="HEATING">heating</option>
+ <option value="COOLING">cooling</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNHumidity">
+ <item-type>Number:Dimensionless</item-type>
+ <label>Humidity</label>
+ <description>Indicates the current relative humidity</description>
+ <category>Humidity</category>
+ <state pattern="%.1f %unit%" readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNTimeToTarget">
+ <item-type>Number:Time</item-type>
+ <label>Time to Target</label>
+ <description>Time left to the target temperature approximately</description>
+ <state pattern="%d %unit%" readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCanHeat" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Can Heat</label>
+ <description>If the thermostat can actually turn on heating</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNCanCool" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Can Cool</label>
+ <description>If the thermostat can actually turn on cooling</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNFanTimerActive" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Fan Timer Active</label>
+ <description>If the fan timer is engaged</description>
+ <state/>
+ </channel-type>
+
+ <channel-type id="WWNFanTimerDuration" advanced="true">
+ <item-type>Number:Time</item-type>
+ <label>Fan Timer Duration</label>
+ <description>Length of time that the fan is set to run</description>
+ <state>
+ <options>
+ <option value="15">15 min</option>
+ <option value="30">30 min</option>
+ <option value="45">45 min</option>
+ <option value="60">1 h</option>
+ <option value="120">2 h</option>
+ <option value="240">4 h</option>
+ <option value="480">8 h</option>
+ <option value="960">16 h</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="WWNFanTimerTimeout" advanced="true">
+ <item-type>DateTime</item-type>
+ <label>Fan Timer Timeout</label>
+ <description>Timestamp when the fan stops running</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNHasFan" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Has Fan</label>
+ <description>If the thermostat can control the fan</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNHasLeaf" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Has Leaf</label>
+ <description>If the thermostat is currently in a leaf mode</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNSunlightCorrectionEnabled" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Sunlight Correction Enabled</label>
+ <description>If sunlight correction is enabled</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNSunlightCorrectionActive" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Sunlight Correction Active</label>
+ <description>If sunlight correction is active</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="WWNUsingEmergencyHeat" advanced="true">
+ <item-type>Switch</item-type>
+ <label>Using Emergency Heat</label>
+ <description>If the system is currently using emergency heat</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="wwn_smoke_detector" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="wwn_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Protect</label>
+ <description>The smoke detector/Nest Protect for the account</description>
+
+ <channels>
+ <channel id="ui_color_state" typeId="WWNUiColorState"/>
+ <channel id="low_battery" typeId="system.low-battery"/>
+ <channel id="co_alarm_state" typeId="WWNCoAlarmState"/>
+ <channel id="smoke_alarm_state" typeId="WWNSmokeAlarmState"/>
+ <channel id="manual_test_active" typeId="WWNManualTestActive"/>
+ <channel id="last_manual_test_time" typeId="WWNLastManualTestTime"/>
+ <channel id="last_connection" typeId="WWNLastConnection"/>
+ </channels>
+
+ <properties>
+ <property name="vendor">Nest</property>
+ </properties>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:wwn_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="wwn_structure" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="wwn_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Structure</label>
+ <description>The Nest structure defines the house the account has setup on Nest.
+ You will only have more than one
+ structure if you have more than one house</description>
+
+ <channels>
+ <channel id="country_code" typeId="WWNCountryCode"/>
+ <channel id="postal_code" typeId="WWNPostalCode"/>
+ <channel id="time_zone" typeId="WWNTimeZone"/>
+ <channel id="peak_period_start_time" typeId="WWNPeakPeriodStartTime"/>
+ <channel id="peak_period_end_time" typeId="WWNPeakPeriodEndTime"/>
+ <channel id="rush_hour_rewards_enrollment" typeId="WWNRushHourRewardsEnrollment"/>
+ <channel id="eta_begin" typeId="WWNEtaBegin"/>
+ <channel id="co_alarm_state" typeId="WWNCoAlarmState"/>
+ <channel id="smoke_alarm_state" typeId="WWNSmokeAlarmState"/>
+ <channel id="security_state" typeId="WWNSecurityState"/>
+ <channel id="away" typeId="WWNAway"/>
+ </channels>
+
+ <properties>
+ <property name="vendor">Nest</property>
+ </properties>
+
+ <representation-property>structureId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:wwn_structure"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nest"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <thing-type id="wwn_thermostat" listed="false">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="wwn_account"/>
+ </supported-bridge-type-refs>
+
+ <label>Nest Thermostat</label>
+ <description>A Thermostat to control the various aspects of the house's HVAC system</description>
+
+ <channels>
+ <channel id="temperature" typeId="WWNTemperature"/>
+ <channel id="humidity" typeId="WWNHumidity"/>
+ <channel id="mode" typeId="WWNMode"/>
+ <channel id="previous_mode" typeId="WWNPreviousMode"/>
+ <channel id="state" typeId="WWNState"/>
+ <channel id="set_point" typeId="WWNSetPoint"/>
+ <channel id="max_set_point" typeId="WWNMaxSetPoint"/>
+ <channel id="min_set_point" typeId="WWNMinSetPoint"/>
+ <channel id="can_heat" typeId="WWNCanHeat"/>
+ <channel id="can_cool" typeId="WWNCanCool"/>
+ <channel id="fan_timer_active" typeId="WWNFanTimerActive"/>
+ <channel id="fan_timer_duration" typeId="WWNFanTimerDuration"/>
+ <channel id="fan_timer_timeout" typeId="WWNFanTimerTimeout"/>
+ <channel id="has_fan" typeId="WWNHasFan"/>
+ <channel id="has_leaf" typeId="WWNHasLeaf"/>
+ <channel id="sunlight_correction_enabled" typeId="WWNSunlightCorrectionEnabled"/>
+ <channel id="sunlight_correction_active" typeId="WWNSunlightCorrectionActive"/>
+ <channel id="using_emergency_heat" typeId="WWNUsingEmergencyHeat"/>
+ <channel id="eco_max_set_point" typeId="WWNEcoMaxSetPoint"/>
+ <channel id="eco_min_set_point" typeId="WWNEcoMinSetPoint"/>
+ <channel id="locked" typeId="WWNLocked"/>
+ <channel id="locked_max_set_point" typeId="WWNLockedMaxSetPoint"/>
+ <channel id="locked_min_set_point" typeId="WWNLockedMinSetPoint"/>
+ <channel id="time_to_target" typeId="WWNTimeToTarget"/>
+ <channel id="last_connection" typeId="WWNLastConnection"/>
+ </channels>
+
+ <properties>
+ <property name="vendor">Nest</property>
+ </properties>
+
+ <representation-property>deviceId</representation-property>
+
+ <config-description-ref uri="thing-type:nest:wwn_device"/>
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.*;
+
+import java.io.IOException;
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubAcknowledgeRequest;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubCreateRequest;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubMessage;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubPullRequest;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubPullResponse;
+import org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses.PubSubReceivedMessage;
+
+/**
+ * Tests (de)serialization of {@link
+ * org.openhab.binding.nest.internal.sdm.dto.PubSubRequestsResponses} from/to JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class PubSubRequestsResponsesTest {
+
+ @Test
+ public void deserializePullSubscriptionResponse() throws IOException {
+ PubSubPullResponse response = fromJson("pull-subscription-response.json", PubSubPullResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ List<PubSubReceivedMessage> receivedMessages = response.receivedMessages;
+ assertThat(receivedMessages, is(notNullValue()));
+ assertThat(receivedMessages, hasSize(3));
+
+ PubSubReceivedMessage receivedMessage = receivedMessages.get(0);
+ assertThat(receivedMessage, is(notNullValue()));
+ assertThat(receivedMessage.ackId, is("AID1"));
+ PubSubMessage message = receivedMessage.message;
+ assertThat(message, is(notNullValue()));
+ assertThat(message.data, is("ZGF0YTE="));
+ assertThat(message.messageId, is("1000000000000001"));
+ assertThat(message.publishTime, is(ZonedDateTime.parse("2021-01-01T01:00:00.000Z")));
+
+ receivedMessage = receivedMessages.get(1);
+ assertThat(receivedMessage, is(notNullValue()));
+ assertThat(receivedMessage.ackId, is("AID2"));
+ message = receivedMessage.message;
+ assertThat(message, is(notNullValue()));
+ assertThat(message.data, is("ZGF0YTI="));
+ assertThat(message.messageId, is("2000000000000002"));
+ assertThat(message.publishTime, is(ZonedDateTime.parse("2021-02-02T02:00:00.000Z")));
+
+ receivedMessage = receivedMessages.get(2);
+ assertThat(receivedMessage, is(notNullValue()));
+ assertThat(receivedMessage.ackId, is("AID3"));
+ message = receivedMessage.message;
+ assertThat(message, is(notNullValue()));
+ assertThat(message.data, is("ZGF0YTM="));
+ assertThat(message.messageId, is("3000000000000003"));
+ assertThat(message.publishTime, is(ZonedDateTime.parse("2021-03-03T03:00:00.000Z")));
+ }
+
+ @Test
+ public void serializeAcknowledgeSubscriptionRequest() throws IOException {
+ String json = toJson(new PubSubAcknowledgeRequest(List.of("AID1", "AID2", "AID3")));
+ assertThat(json, is(fromFile("acknowledge-subscription-request.json")));
+ }
+
+ @Test
+ public void serializeCreateSubscriptionRequest() throws IOException {
+ String json = toJson(new PubSubCreateRequest("projects/sdm-prod/topics/enterprise-project-id", true));
+ assertThat(json, is(fromFile("create-subscription-request.json")));
+ }
+
+ @Test
+ public void serializePullSubscriptionRequest() throws IOException {
+ String json = toJson(new PubSubPullRequest(123));
+ assertThat(json, is(fromFile("pull-subscription-request.json")));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.*;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMCameraRtspStreamUrls;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMExtendCameraRtspStreamRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMExtendCameraRtspStreamResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMExtendCameraRtspStreamResults;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraImageResults;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraRtspStreamRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraRtspStreamResponse;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMGenerateCameraRtspStreamResults;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetFanTimerRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatCoolSetpointRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatEcoModeRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatHeatSetpointRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatModeRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMSetThermostatRangeSetpointRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMCommands.SDMStopCameraRtspStreamRequest;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMFanTimerMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatEcoMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatMode;
+
+/**
+ * Tests (de)serialization of {@link org.openhab.binding.nest.internal.sdm.dto.SDMCommands} requests
+ * and responses from/to JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMCommandsTest {
+
+ @Test
+ public void deserializeExtendCameraRtspStreamResponse() throws IOException {
+ SDMExtendCameraRtspStreamResponse response = fromJson("extend-camera-rtsp-stream-response.json",
+ SDMExtendCameraRtspStreamResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ SDMExtendCameraRtspStreamResults results = response.results;
+ assertThat(results, is(notNullValue()));
+
+ assertThat(results.streamExtensionToken, is("dGNUlTU2CjY5Y3VKaTZwR3o4Y1..."));
+ assertThat(results.streamToken, is("g.0.newStreamingToken"));
+ assertThat(results.expiresAt, is(ZonedDateTime.parse("2018-01-04T18:30:00.000Z")));
+ }
+
+ @Test
+ public void deserializeGenerateCameraImageResponse() throws IOException {
+ SDMGenerateCameraImageResponse response = fromJson("generate-camera-image-response.json",
+ SDMGenerateCameraImageResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ SDMGenerateCameraImageResults results = response.results;
+ assertThat(results, is(notNullValue()));
+ assertThat(results.url, is("https://domain/sdm_resource/dGNUlTU2CjY5Y3VKaTZwR3o4Y1..."));
+ assertThat(results.token, is("g.0.eventToken"));
+ }
+
+ @Test
+ public void deserializeGenerateCameraRtspStreamResponse() throws IOException {
+ SDMGenerateCameraRtspStreamResponse response = fromJson("generate-camera-rtsp-stream-response.json",
+ SDMGenerateCameraRtspStreamResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ SDMGenerateCameraRtspStreamResults results = response.results;
+ assertThat(results, is(notNullValue()));
+
+ SDMCameraRtspStreamUrls streamUrls = results.streamUrls;
+ assertThat(streamUrls, is(notNullValue()));
+ assertThat(streamUrls.rtspUrl, is("rtsps://someurl.com/CjY5Y3VKaTZwR3o4Y19YbTVfMF...?auth=g.0.streamingToken"));
+
+ assertThat(results.streamExtensionToken, is("CjY5Y3VKaTZwR3o4Y19YbTVfMF..."));
+ assertThat(results.streamToken, is("g.0.streamingToken"));
+ assertThat(results.expiresAt, is(ZonedDateTime.parse("2018-01-04T18:30:00.000Z")));
+ }
+
+ @Test
+ public void serializeExtendCameraRtspStreamRequest() throws IOException {
+ String json = toJson(new SDMExtendCameraRtspStreamRequest("CjY5Y3VKaTZwR3o4Y19YbTVfMF..."));
+ assertThat(json, is(fromFile("extend-camera-rtsp-stream-request.json")));
+ }
+
+ @Test
+ public void serializeGenerateCameraImageRequest() throws IOException {
+ String json = toJson(new SDMGenerateCameraImageRequest("FWWVQVUdGNUlTU2V4MGV2aTNXV..."));
+ assertThat(json, is(fromFile("generate-camera-image-request.json")));
+ }
+
+ @Test
+ public void serializeGenerateCameraRtspStreamRequest() throws IOException {
+ String json = toJson(new SDMGenerateCameraRtspStreamRequest());
+ assertThat(json, is(fromFile("generate-camera-rtsp-stream-request.json")));
+ }
+
+ @Test
+ public void serializeSetFanTimerRequestWithDuration() throws IOException {
+ String json = toJson(new SDMSetFanTimerRequest(SDMFanTimerMode.ON, Duration.ofSeconds(3600)));
+ assertThat(json, is(fromFile("set-fan-timer-request-with-duration.json")));
+ }
+
+ @Test
+ public void serializeSetFanTimerRequestWithoutDuration() throws IOException {
+ String json = toJson(new SDMSetFanTimerRequest(SDMFanTimerMode.ON));
+ assertThat(json, is(fromFile("set-fan-timer-request-without-duration.json")));
+ }
+
+ @Test
+ public void serializeSetThermostatCoolSetpointRequest() throws IOException {
+ String json = toJson(new SDMSetThermostatCoolSetpointRequest(new BigDecimal("20.0")));
+ assertThat(json, is(fromFile("set-thermostat-cool-setpoint-request.json")));
+ }
+
+ @Test
+ public void serializeSetThermostatEcoModeRequest() throws IOException {
+ String json = toJson(new SDMSetThermostatEcoModeRequest(SDMThermostatEcoMode.MANUAL_ECO));
+ assertThat(json, is(fromFile("set-thermostat-eco-mode-request.json")));
+ }
+
+ @Test
+ public void serializeSetThermostatHeatSetpointRequest() throws IOException {
+ String json = toJson(new SDMSetThermostatHeatSetpointRequest(new BigDecimal("15.0")));
+ assertThat(json, is(fromFile("set-thermostat-heat-setpoint-request.json")));
+ }
+
+ @Test
+ public void serializeSetThermostatModeRequest() throws IOException {
+ String json = toJson(new SDMSetThermostatModeRequest(SDMThermostatMode.HEATCOOL));
+ assertThat(json, is(fromFile("set-thermostat-mode-request.json")));
+ }
+
+ @Test
+ public void serializeSetThermostatRangeSetpointRequest() throws IOException {
+ String json = toJson(new SDMSetThermostatRangeSetpointRequest(new BigDecimal("15.0"), new BigDecimal("20.0")));
+ assertThat(json, is(fromFile("set-thermostat-range-setpoint-request.json")));
+ }
+
+ @Test
+ public void serializeStopCameraRtspStreamRequest() throws IOException {
+ String json = toJson(new SDMStopCameraRtspStreamRequest("CjY5Y3VKaTZwR3o4Y19YbTVfMF..."));
+ assertThat(json, is(fromFile("stop-camera-rtsp-stream-request.json")));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.openhab.binding.nest.internal.sdm.dto.SDMGson.GSON;
+
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.io.UnsupportedEncodingException;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.stream.JsonWriter;
+
+/**
+ * Utility class for working with Nest SDM test data in unit tests.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMDataUtil {
+
+ public static Reader openDataReader(String fileName) throws UnsupportedEncodingException, FileNotFoundException {
+ String packagePath = (SDMDataUtil.class.getPackage().getName()).replaceAll("\\.", "/");
+ String filePath = "src/test/resources/" + packagePath + "/" + fileName;
+
+ InputStream inputStream = new FileInputStream(filePath);
+ return new InputStreamReader(inputStream, "UTF-8");
+ }
+
+ public static <T> T fromJson(String fileName, Class<T> dataClass) throws IOException {
+ try (Reader reader = openDataReader(fileName)) {
+ return GSON.fromJson(reader, dataClass);
+ }
+ }
+
+ public static String fromFile(String fileName) throws IOException {
+ try (Reader reader = openDataReader(fileName)) {
+ return new BufferedReader(reader).lines().parallel().collect(Collectors.joining("\n"));
+ }
+ }
+
+ public static String toJson(Object object) {
+ StringWriter writer = new StringWriter();
+ JsonWriter jsonWriter = new JsonWriter(writer);
+ jsonWriter.setIndent(" ");
+ GSON.toJson(object, object.getClass(), jsonWriter);
+ return writer.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.fromJson;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMCameraImageTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMCameraLiveStreamTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMConnectivityStatus;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMConnectivityTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMDeviceInfoTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMDeviceSettingsTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMFanTimerMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMFanTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMHumidityTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMHvacStatus;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMResolution;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMTemperatureScale;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMTemperatureTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatEcoMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatEcoTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatHvacTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatMode;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatModeTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatTemperatureSetpointTrait;
+
+/**
+ * Tests deserialization of {@link org.openhab.binding.nest.internal.sdm.dto.SDMDevice}s from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMDeviceTest {
+
+ @Test
+ public void deserializeThermostatDevice() throws IOException {
+ SDMDevice device = getThermostatDevice();
+ assertThat(device, is(notNullValue()));
+
+ assertThat(device.name.name, is("enterprises/project-id/devices/thermostat-device-id"));
+ assertThat(device.type, is(SDMDeviceType.THERMOSTAT));
+
+ SDMTraits traits = device.traits;
+ assertThat(traits, is(notNullValue()));
+ assertThat(traits.traitList(), hasSize(10));
+
+ SDMDeviceInfoTrait deviceInfo = traits.deviceInfo;
+ assertThat(deviceInfo, is(notNullValue()));
+ assertThat(deviceInfo.customName, is(""));
+
+ SDMHumidityTrait humidity = traits.humidity;
+ assertThat(humidity, is(notNullValue()));
+ assertThat(humidity.ambientHumidityPercent, is(new BigDecimal(26)));
+
+ SDMConnectivityTrait connectivity = traits.connectivity;
+ assertThat(connectivity, is(notNullValue()));
+ assertThat(connectivity.status, is(SDMConnectivityStatus.ONLINE));
+
+ SDMFanTrait fan = traits.fan;
+ assertThat(fan, is(notNullValue()));
+ assertThat(fan.timerMode, is(SDMFanTimerMode.ON));
+ assertThat(fan.timerTimeout, is(ZonedDateTime.parse("2019-05-10T03:22:54Z")));
+
+ SDMThermostatModeTrait thermostatMode = traits.thermostatMode;
+ assertThat(thermostatMode, is(notNullValue()));
+ assertThat(thermostatMode.mode, is(SDMThermostatMode.HEAT));
+ assertThat(thermostatMode.availableModes, is(List.of(SDMThermostatMode.HEAT, SDMThermostatMode.OFF)));
+
+ SDMThermostatEcoTrait thermostatEco = traits.thermostatEco;
+ assertThat(thermostatEco, is(notNullValue()));
+ assertThat(thermostatEco.availableModes,
+ is(List.of(SDMThermostatEcoMode.OFF, SDMThermostatEcoMode.MANUAL_ECO)));
+ assertThat(thermostatEco.mode, is(SDMThermostatEcoMode.OFF));
+ assertThat(thermostatEco.heatCelsius, is(new BigDecimal("15.34473")));
+ assertThat(thermostatEco.coolCelsius, is(new BigDecimal("24.44443")));
+
+ SDMThermostatHvacTrait thermostatHvac = traits.thermostatHvac;
+ assertThat(thermostatHvac, is(notNullValue()));
+ assertThat(thermostatHvac.status, is(SDMHvacStatus.OFF));
+
+ SDMDeviceSettingsTrait deviceSettings = traits.deviceSettings;
+ assertThat(deviceSettings, is(notNullValue()));
+ assertThat(deviceSettings.temperatureScale, is(SDMTemperatureScale.CELSIUS));
+
+ SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint = traits.thermostatTemperatureSetpoint;
+ assertThat(thermostatTemperatureSetpoint, is(notNullValue()));
+ assertThat(thermostatTemperatureSetpoint.heatCelsius, is(new BigDecimal("14.92249")));
+ assertThat(thermostatTemperatureSetpoint.coolCelsius, is(nullValue()));
+
+ SDMTemperatureTrait temperature = traits.temperature;
+ assertThat(temperature, is(notNullValue()));
+ assertThat(temperature.ambientTemperatureCelsius, is(new BigDecimal("19.73")));
+
+ List<SDMParentRelation> parentRelations = device.parentRelations;
+ assertThat(parentRelations, is(notNullValue()));
+ assertThat(parentRelations, hasSize(1));
+
+ assertThat(parentRelations.get(0).parent.name,
+ is("enterprises/project-id/structures/structure-id/rooms/thermostat-room-id"));
+ assertThat(parentRelations.get(0).displayName, is("Thermostat Room Name"));
+ }
+
+ protected SDMDevice getThermostatDevice() throws IOException {
+ return fromJson("thermostat-device-response.json", SDMDevice.class);
+ }
+
+ @Test
+ public void deserializeCameraDevice() throws IOException {
+ SDMDevice device = getCameraDevice();
+ assertThat(device, is(notNullValue()));
+
+ assertThat(device.name.name, is("enterprises/project-id/devices/camera-device-id"));
+ assertThat(device.type, is(SDMDeviceType.CAMERA));
+
+ SDMTraits traits = device.traits;
+ assertThat(traits, is(notNullValue()));
+ assertThat(traits.traitList(), hasSize(7));
+
+ SDMDeviceInfoTrait deviceInfo = traits.deviceInfo;
+ assertThat(deviceInfo, is(notNullValue()));
+ assertThat(deviceInfo.customName, is(""));
+
+ SDMConnectivityTrait connectivity = traits.connectivity;
+ assertThat(connectivity, is(nullValue()));
+
+ SDMCameraLiveStreamTrait cameraLiveStream = traits.cameraLiveStream;
+ assertThat(cameraLiveStream, is(notNullValue()));
+
+ SDMResolution maxVideoResolution = cameraLiveStream.maxVideoResolution;
+ assertThat(maxVideoResolution, is(notNullValue()));
+ assertThat(maxVideoResolution.width, is(640));
+ assertThat(maxVideoResolution.height, is(480));
+
+ assertThat(cameraLiveStream.videoCodecs, is(List.of("H264")));
+ assertThat(cameraLiveStream.audioCodecs, is(List.of("AAC")));
+
+ SDMCameraImageTrait cameraImage = traits.cameraImage;
+ assertThat(cameraImage, is(notNullValue()));
+
+ SDMResolution maxImageResolution = cameraImage.maxImageResolution;
+ assertThat(maxImageResolution, is(notNullValue()));
+ assertThat(maxImageResolution.width, is(1920));
+ assertThat(maxImageResolution.height, is(1200));
+
+ assertThat(traits.cameraPerson, is(notNullValue()));
+ assertThat(traits.cameraSound, is(notNullValue()));
+ assertThat(traits.cameraMotion, is(notNullValue()));
+ assertThat(traits.cameraEventImage, is(notNullValue()));
+ assertThat(traits.doorbellChime, is(nullValue()));
+
+ List<SDMParentRelation> parentRelations = device.parentRelations;
+ assertThat(parentRelations, is(notNullValue()));
+ assertThat(parentRelations, hasSize(1));
+
+ assertThat(parentRelations.get(0).parent.name,
+ is("enterprises/project-id/structures/structure-id/rooms/camera-room-id"));
+ assertThat(parentRelations.get(0).displayName, is("Camera Room Name"));
+ }
+
+ protected SDMDevice getCameraDevice() throws IOException {
+ return fromJson("camera-device-response.json", SDMDevice.class);
+ }
+
+ @Test
+ public void deserializeDisplayDevice() throws IOException {
+ SDMDevice device = getDisplayDevice();
+ assertThat(device, is(notNullValue()));
+
+ assertThat(device.name.name, is("enterprises/project-id/devices/display-device-id"));
+ assertThat(device.type, is(SDMDeviceType.DISPLAY));
+
+ SDMTraits traits = device.traits;
+ assertThat(traits, is(notNullValue()));
+ assertThat(traits.traitList(), hasSize(7));
+
+ SDMDeviceInfoTrait deviceInfo = traits.deviceInfo;
+ assertThat(deviceInfo, is(notNullValue()));
+ assertThat(deviceInfo.customName, is(""));
+
+ SDMConnectivityTrait connectivity = traits.connectivity;
+ assertThat(connectivity, is(nullValue()));
+
+ SDMCameraLiveStreamTrait cameraLiveStream = traits.cameraLiveStream;
+ assertThat(cameraLiveStream, is(notNullValue()));
+
+ SDMResolution maxVideoResolution = cameraLiveStream.maxVideoResolution;
+ assertThat(maxVideoResolution, is(notNullValue()));
+ assertThat(maxVideoResolution.width, is(640));
+ assertThat(maxVideoResolution.height, is(480));
+
+ assertThat(cameraLiveStream.videoCodecs, is(List.of("H264")));
+ assertThat(cameraLiveStream.audioCodecs, is(List.of("AAC")));
+
+ SDMCameraImageTrait cameraImage = traits.cameraImage;
+ assertThat(cameraImage, is(notNullValue()));
+
+ SDMResolution maxImageResolution = cameraImage.maxImageResolution;
+ assertThat(maxImageResolution, is(notNullValue()));
+ assertThat(maxImageResolution.width, is(1920));
+ assertThat(maxImageResolution.height, is(1200));
+
+ assertThat(traits.cameraPerson, is(notNullValue()));
+ assertThat(traits.cameraSound, is(notNullValue()));
+ assertThat(traits.cameraMotion, is(notNullValue()));
+ assertThat(traits.cameraEventImage, is(notNullValue()));
+ assertThat(traits.doorbellChime, is(nullValue()));
+
+ List<SDMParentRelation> parentRelations = device.parentRelations;
+ assertThat(parentRelations, is(notNullValue()));
+ assertThat(parentRelations, hasSize(1));
+
+ assertThat(parentRelations.get(0).parent.name,
+ is("enterprises/project-id/structures/structure-id/rooms/display-room-id"));
+ assertThat(parentRelations.get(0).displayName, is("Display Room Name"));
+ }
+
+ protected SDMDevice getDisplayDevice() throws IOException {
+ return fromJson("display-device-response.json", SDMDevice.class);
+ }
+
+ @Test
+ public void deserializeDoorbellDevice() throws IOException {
+ SDMDevice device = getDoorbellDevice();
+ assertThat(device, is(notNullValue()));
+
+ assertThat(device.name.name, is("enterprises/project-id/devices/doorbell-device-id"));
+ assertThat(device.type, is(SDMDeviceType.DOORBELL));
+
+ SDMTraits traits = device.traits;
+ assertThat(traits, is(notNullValue()));
+ assertThat(traits.traitList(), hasSize(8));
+
+ SDMDeviceInfoTrait deviceInfo = traits.deviceInfo;
+ assertThat(deviceInfo, is(notNullValue()));
+ assertThat(deviceInfo.customName, is(""));
+
+ SDMConnectivityTrait connectivity = traits.connectivity;
+ assertThat(connectivity, is(nullValue()));
+
+ SDMCameraLiveStreamTrait cameraLiveStream = traits.cameraLiveStream;
+ assertThat(cameraLiveStream, is(notNullValue()));
+
+ SDMResolution maxVideoResolution = cameraLiveStream.maxVideoResolution;
+ assertThat(maxVideoResolution, is(notNullValue()));
+ assertThat(maxVideoResolution.width, is(640));
+ assertThat(maxVideoResolution.height, is(480));
+
+ assertThat(cameraLiveStream.videoCodecs, is(List.of("H264")));
+ assertThat(cameraLiveStream.audioCodecs, is(List.of("AAC")));
+
+ SDMCameraImageTrait cameraImage = traits.cameraImage;
+ assertThat(cameraImage, is(notNullValue()));
+
+ SDMResolution maxImageResolution = cameraImage.maxImageResolution;
+ assertThat(maxImageResolution, is(notNullValue()));
+ assertThat(maxImageResolution.width, is(1920));
+ assertThat(maxImageResolution.height, is(1200));
+
+ assertThat(traits.cameraPerson, is(notNullValue()));
+ assertThat(traits.cameraSound, is(notNullValue()));
+ assertThat(traits.cameraMotion, is(notNullValue()));
+ assertThat(traits.cameraEventImage, is(notNullValue()));
+ assertThat(traits.doorbellChime, is(notNullValue()));
+
+ List<SDMParentRelation> parentRelations = device.parentRelations;
+ assertThat(parentRelations, is(notNullValue()));
+ assertThat(parentRelations, hasSize(1));
+
+ assertThat(parentRelations.get(0).parent.name,
+ is("enterprises/project-id/structures/structure-id/rooms/doorbell-room-id"));
+ assertThat(parentRelations.get(0).displayName, is("Doorbell Room Name"));
+ }
+
+ protected SDMDevice getDoorbellDevice() throws IOException {
+ return fromJson("doorbell-device-response.json", SDMDevice.class);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.fromJson;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMError.SDMErrorDetails;
+
+/**
+ * Tests deserialization of {@link org.openhab.binding.nest.internal.sdm.dto.SDMError}s from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class SDMErrorTest {
+
+ @Test
+ public void deserializeFailedPreconditionError() throws IOException {
+ SDMError error = fromJson("failed-precondition-error.json", SDMError.class);
+ assertThat(error, is(notNullValue()));
+
+ SDMErrorDetails details = error.error;
+ assertThat(details, is(notNullValue()));
+ assertThat(details.code, is(400));
+ assertThat(details.message, is("Thermostat fan unavailable."));
+ assertThat(details.status, is("FAILED_PRECONDITION"));
+ }
+
+ @Test
+ public void deserializeNotFoundError() throws IOException {
+ SDMError error = fromJson("not-found-error.json", SDMError.class);
+ assertThat(error, is(notNullValue()));
+
+ SDMErrorDetails details = error.error;
+ assertThat(details, is(notNullValue()));
+ assertThat(details.code, is(404));
+ assertThat(details.message, is("Device enterprises/project-id/devices/device-id not found."));
+ assertThat(details.status, is("NOT_FOUND"));
+ }
+
+ @Test
+ public void deserializeResponseWithoutError() throws IOException {
+ SDMError error = fromJson("list-devices-response.json", SDMError.class);
+ assertThat(error, is(notNullValue()));
+
+ SDMErrorDetails details = error.error;
+ assertThat(details, is(nullValue()));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.fromJson;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMDeviceEvent;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMRelationUpdate;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMRelationUpdateType;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMResourceUpdate;
+import org.openhab.binding.nest.internal.sdm.dto.SDMEvent.SDMResourceUpdateEvents;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMHvacStatus;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMTemperatureTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatHvacTrait;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMThermostatTemperatureSetpointTrait;
+
+/**
+ * Tests deserialization of {@link org.openhab.binding.nest.internal.sdm.dto.SDMEvent}s from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMEventTest {
+
+ @Test
+ public void deserializeResourceUpdateEvent() throws IOException {
+ SDMEvent event = fromJson("resource-update-event.json", SDMEvent.class);
+ assertThat(event, is(notNullValue()));
+
+ assertThat(event.eventId, is("053a5f98-8c9d-426e-acf1-6b8660558832"));
+ assertThat(event.timestamp, is(ZonedDateTime.parse("2019-01-01T00:00:01Z")));
+
+ assertThat(event.relationUpdate, is(nullValue()));
+
+ SDMResourceUpdate resourceUpdate = event.resourceUpdate;
+ assertThat(resourceUpdate, is(notNullValue()));
+ assertThat(resourceUpdate.name.name, is("enterprises/project-id/devices/device-id"));
+
+ SDMTraits traits = resourceUpdate.traits;
+ assertThat(traits, is(notNullValue()));
+ assertThat(traits.traitList(), hasSize(3));
+
+ SDMResourceUpdateEvents events = resourceUpdate.events;
+ assertThat(events, is(notNullValue()));
+ assertThat(events.eventList(), hasSize(4));
+
+ SDMDeviceEvent cameraMotionEvent = events.cameraMotionEvent;
+ assertThat(cameraMotionEvent, is(notNullValue()));
+ assertThat(cameraMotionEvent.eventSessionId, is("ESI1"));
+ assertThat(cameraMotionEvent.eventId, is("EID1"));
+
+ SDMDeviceEvent cameraPersonEvent = events.cameraPersonEvent;
+ assertThat(cameraPersonEvent, is(notNullValue()));
+ assertThat(cameraPersonEvent.eventSessionId, is("ESI2"));
+ assertThat(cameraPersonEvent.eventId, is("EID2"));
+
+ SDMDeviceEvent cameraSoundEvent = events.cameraSoundEvent;
+ assertThat(cameraSoundEvent, is(notNullValue()));
+ assertThat(cameraSoundEvent.eventSessionId, is("ESI3"));
+ assertThat(cameraSoundEvent.eventId, is("EID3"));
+
+ SDMDeviceEvent doorbellChimeEvent = events.doorbellChimeEvent;
+ assertThat(doorbellChimeEvent, is(notNullValue()));
+ assertThat(doorbellChimeEvent.eventSessionId, is("ESI4"));
+ assertThat(doorbellChimeEvent.eventId, is("EID4"));
+
+ SDMTemperatureTrait temperature = traits.temperature;
+ assertThat(temperature, is(notNullValue()));
+ assertThat(temperature.ambientTemperatureCelsius, is(new BigDecimal("19.73")));
+
+ SDMThermostatHvacTrait thermostatHvac = traits.thermostatHvac;
+ assertThat(thermostatHvac, is(notNullValue()));
+ assertThat(thermostatHvac.status, is(SDMHvacStatus.OFF));
+
+ SDMThermostatTemperatureSetpointTrait thermostatTemperatureSetpoint = traits.thermostatTemperatureSetpoint;
+ assertThat(thermostatTemperatureSetpoint, is(notNullValue()));
+ assertThat(thermostatTemperatureSetpoint.heatCelsius, is(new BigDecimal("14.92249")));
+ assertThat(thermostatTemperatureSetpoint.coolCelsius, is(nullValue()));
+
+ assertThat(event.userId, is("AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"));
+ assertThat(event.resourceGroup, is(List.of(new SDMResourceName("enterprises/project-id/devices/device-id"))));
+ }
+
+ @Test
+ public void deserializeRelationCreatedEvent() throws IOException {
+ SDMEvent event = fromJson("relation-created-event.json", SDMEvent.class);
+ assertThat(event, is(notNullValue()));
+
+ assertThat(event.eventId, is("0120ecc7-3b57-4eb4-9941-91609f189fb4"));
+ assertThat(event.timestamp, is(ZonedDateTime.parse("2019-01-01T00:00:01Z")));
+
+ SDMRelationUpdate relationUpdate = event.relationUpdate;
+ assertThat(relationUpdate, is(notNullValue()));
+ assertThat(relationUpdate.type, is(SDMRelationUpdateType.CREATED));
+ assertThat(relationUpdate.subject.name, is("enterprises/project-id/structures/structure-id"));
+ assertThat(relationUpdate.object.name, is("enterprises/project-id/devices/device-id"));
+
+ assertThat(event.resourceUpdate, is(nullValue()));
+ assertThat(event.userId, is("AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"));
+ assertThat(event.resourceGroup, is(nullValue()));
+ }
+
+ @Test
+ public void deserializeRelationDeletedEvent() throws IOException {
+ SDMEvent event = fromJson("relation-deleted-event.json", SDMEvent.class);
+ assertThat(event, is(notNullValue()));
+
+ assertThat(event.eventId, is("0120ecc7-3b57-4eb4-9941-91609f189fb4"));
+ assertThat(event.timestamp, is(ZonedDateTime.parse("2019-01-01T00:00:01Z")));
+
+ SDMRelationUpdate relationUpdate = event.relationUpdate;
+ assertThat(relationUpdate, is(notNullValue()));
+ assertThat(relationUpdate.type, is(SDMRelationUpdateType.DELETED));
+ assertThat(relationUpdate.subject.name, is("enterprises/project-id/structures/structure-id"));
+ assertThat(relationUpdate.object.name, is("enterprises/project-id/devices/device-id"));
+
+ assertThat(event.resourceUpdate, is(nullValue()));
+ assertThat(event.userId, is("AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"));
+ assertThat(event.resourceGroup, is(nullValue()));
+ }
+
+ @Test
+ public void deserializeRelationUpdatedEvent() throws IOException {
+ SDMEvent event = fromJson("relation-updated-event.json", SDMEvent.class);
+ assertThat(event, is(notNullValue()));
+
+ assertThat(event.eventId, is("0120ecc7-3b57-4eb4-9941-91609f189fb4"));
+ assertThat(event.timestamp, is(ZonedDateTime.parse("2019-01-01T00:00:01Z")));
+
+ SDMRelationUpdate relationUpdate = event.relationUpdate;
+ assertThat(relationUpdate, is(notNullValue()));
+ assertThat(relationUpdate.type, is(SDMRelationUpdateType.UPDATED));
+ assertThat(relationUpdate.subject.name, is("enterprises/project-id/structures/structure-id"));
+ assertThat(relationUpdate.object.name, is("enterprises/project-id/devices/device-id"));
+
+ assertThat(event.resourceUpdate, is(nullValue()));
+ assertThat(event.userId, is("AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"));
+ assertThat(event.resourceGroup, is(nullValue()));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.fromJson;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+
+/**
+ * Tests deserialization of {@link
+ * org.openhab.binding.nest.internal.sdm.dto.SDMListDevicesResponse}s from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMListDevicesResponseTest extends SDMDeviceTest {
+
+ private List<SDMDevice> devices = List.of();
+
+ @BeforeEach
+ public void deserializeListDevicesResponse() throws IOException {
+ SDMListDevicesResponse response = fromJson("list-devices-response.json", SDMListDevicesResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ devices = response.devices;
+ assertThat(devices, is(notNullValue()));
+ assertThat(devices, hasSize(4));
+ }
+
+ @Override
+ protected SDMDevice getThermostatDevice() throws IOException {
+ return devices.get(0);
+ }
+
+ @Override
+ protected SDMDevice getCameraDevice() throws IOException {
+ return devices.get(1);
+ }
+
+ @Override
+ protected SDMDevice getDisplayDevice() throws IOException {
+ return devices.get(2);
+ }
+
+ @Override
+ protected SDMDevice getDoorbellDevice() throws IOException {
+ return devices.get(3);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.fromJson;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMRoomInfoTrait;
+
+/**
+ * Tests deserialization of {@link org.openhab.binding.nest.internal.sdm.dto.SDMListRoomsResponse}s
+ * from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMListRoomsResponseTest {
+
+ @Test
+ public void deserializeListDevicesResponse() throws IOException {
+ SDMListRoomsResponse response = fromJson("list-rooms-response.json", SDMListRoomsResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ List<SDMRoom> rooms = response.rooms;
+ assertThat(rooms, is(notNullValue()));
+ assertThat(rooms, hasSize(2));
+
+ SDMRoom room = rooms.get(0);
+ assertThat(room, is(notNullValue()));
+ assertThat(room.name.name, is("enterprises/project-id/structures/structure-id/rooms/kitchen-room-id"));
+ SDMTraits traits = room.traits;
+ assertThat(traits.traitList(), hasSize(1));
+ SDMRoomInfoTrait roomInfo = room.traits.roomInfo;
+ assertThat(roomInfo, is(notNullValue()));
+ assertThat(roomInfo.customName, is("Kitchen"));
+
+ room = rooms.get(1);
+ assertThat(room, is(notNullValue()));
+ assertThat(room.name.name, is("enterprises/project-id/structures/structure-id/rooms/living-room-id"));
+ traits = room.traits;
+ assertThat(traits.traitList(), hasSize(1));
+ roomInfo = room.traits.roomInfo;
+ assertThat(roomInfo, is(notNullValue()));
+ assertThat(roomInfo.customName, is("Living"));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.sdm.dto.SDMDataUtil.fromJson;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMTraits.SDMStructureInfoTrait;
+
+/**
+ * Tests deserialization of {@link
+ * org.openhab.binding.nest.internal.sdm.dto.SDMListStructuresResponse}s from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMListStructuresResponseTest {
+
+ @Test
+ public void deserializeListDevicesResponse() throws IOException {
+ SDMListStructuresResponse response = fromJson("list-structures-response.json", SDMListStructuresResponse.class);
+ assertThat(response, is(notNullValue()));
+
+ List<SDMStructure> structures = response.structures;
+ assertThat(structures, is(notNullValue()));
+ assertThat(structures, hasSize(2));
+
+ SDMStructure structure = structures.get(0);
+ assertThat(structure, is(notNullValue()));
+ assertThat(structure.name.name, is("enterprises/project-id/structures/beach-house-structure-id"));
+ SDMTraits traits = structure.traits;
+ assertThat(traits.traitList(), hasSize(1));
+ SDMStructureInfoTrait structureInfo = structure.traits.structureInfo;
+ assertThat(structureInfo, is(notNullValue()));
+ assertThat(structureInfo.customName, is("Beach House"));
+
+ structure = structures.get(1);
+ assertThat(structure, is(notNullValue()));
+ assertThat(structure.name.name, is("enterprises/project-id/structures/home-structure-id"));
+ traits = structure.traits;
+ assertThat(traits.traitList(), hasSize(1));
+ structureInfo = structure.traits.structureInfo;
+ assertThat(structureInfo, is(notNullValue()));
+ assertThat(structureInfo.customName, is("Home"));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.sdm.dto;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.core.Is.is;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.sdm.dto.SDMResourceName.SDMResourceNameType;
+
+/**
+ * Tests the data provided by {@link org.openhab.binding.nest.internal.sdm.dto.SDMResourceName}
+ * based on resource name strings.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class SDMResourceNameTest {
+
+ @Test
+ public void nameless() {
+ SDMResourceName resourceName = SDMResourceName.NAMELESS;
+ assertThat(resourceName.name, is(emptyString()));
+ assertThat(resourceName.projectId, is(emptyString()));
+ assertThat(resourceName.deviceId, is(emptyString()));
+ assertThat(resourceName.structureId, is(emptyString()));
+ assertThat(resourceName.roomId, is(emptyString()));
+ assertThat(resourceName.type, is(SDMResourceNameType.UNKNOWN));
+ }
+
+ @Test
+ public void deviceName() {
+ String name = "enterprises/project-id/devices/device-id";
+
+ SDMResourceName resourceName = new SDMResourceName(name);
+ assertThat(resourceName.name, is(name));
+ assertThat(resourceName.projectId, is("project-id"));
+ assertThat(resourceName.deviceId, is("device-id"));
+ assertThat(resourceName.structureId, is(emptyString()));
+ assertThat(resourceName.roomId, is(emptyString()));
+ assertThat(resourceName.type, is(SDMResourceNameType.DEVICE));
+ }
+
+ @Test
+ public void structureName() {
+ String name = "enterprises/project-id/structures/structure-id";
+
+ SDMResourceName resourceName = new SDMResourceName(name);
+ assertThat(resourceName.name, is(name));
+ assertThat(resourceName.projectId, is("project-id"));
+ assertThat(resourceName.deviceId, is(emptyString()));
+ assertThat(resourceName.structureId, is("structure-id"));
+ assertThat(resourceName.roomId, is(emptyString()));
+ assertThat(resourceName.type, is(SDMResourceNameType.STRUCTURE));
+ }
+
+ @Test
+ public void roomName() {
+ String name = "enterprises/project-id/structures/structure-id/rooms/room-id";
+
+ SDMResourceName resourceName = new SDMResourceName(name);
+ assertThat(resourceName.name, is(name));
+ assertThat(resourceName.projectId, is("project-id"));
+ assertThat(resourceName.deviceId, is(emptyString()));
+ assertThat(resourceName.structureId, is("structure-id"));
+ assertThat(resourceName.roomId, is("room-id"));
+ assertThat(resourceName.type, is(SDMResourceNameType.ROOM));
+ }
+}
--- /dev/null
+{
+ "ackIds": [
+ "AID1",
+ "AID2",
+ "AID3"
+ ]
+}
--- /dev/null
+{
+ "name": "enterprises/project-id/devices/camera-device-id",
+ "type": "sdm.devices.types.CAMERA",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/camera-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.CameraLiveStream": {
+ "maxVideoResolution": {
+ "width": 640,
+ "height": 480
+ },
+ "videoCodecs": [
+ "H264"
+ ],
+ "audioCodecs": [
+ "AAC"
+ ]
+ },
+ "sdm.devices.traits.CameraImage": {
+ "maxImageResolution": {
+ "width": 1920,
+ "height": 1200
+ }
+ },
+ "sdm.devices.traits.CameraPerson": {},
+ "sdm.devices.traits.CameraSound": {},
+ "sdm.devices.traits.CameraMotion": {},
+ "sdm.devices.traits.CameraEventImage": {}
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/camera-room-id",
+ "displayName": "Camera Room Name"
+ }
+ ]
+}
--- /dev/null
+{
+ "topic": "projects/sdm-prod/topics/enterprise-project-id",
+ "enableMessageOrdering": true
+}
--- /dev/null
+{
+ "name": "enterprises/project-id/devices/display-device-id",
+ "type": "sdm.devices.types.DISPLAY",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/display-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.CameraLiveStream": {
+ "maxVideoResolution": {
+ "width": 640,
+ "height": 480
+ },
+ "videoCodecs": [
+ "H264"
+ ],
+ "audioCodecs": [
+ "AAC"
+ ]
+ },
+ "sdm.devices.traits.CameraImage": {
+ "maxImageResolution": {
+ "width": 1920,
+ "height": 1200
+ }
+ },
+ "sdm.devices.traits.CameraPerson": {},
+ "sdm.devices.traits.CameraSound": {},
+ "sdm.devices.traits.CameraMotion": {},
+ "sdm.devices.traits.CameraEventImage": {}
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/display-room-id",
+ "displayName": "Display Room Name"
+ }
+ ]
+}
--- /dev/null
+{
+ "name": "enterprises/project-id/devices/doorbell-device-id",
+ "type": "sdm.devices.types.DOORBELL",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/doorbell-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.CameraLiveStream": {
+ "maxVideoResolution": {
+ "width": 640,
+ "height": 480
+ },
+ "videoCodecs": [
+ "H264"
+ ],
+ "audioCodecs": [
+ "AAC"
+ ]
+ },
+ "sdm.devices.traits.CameraImage": {
+ "maxImageResolution": {
+ "width": 1920,
+ "height": 1200
+ }
+ },
+ "sdm.devices.traits.CameraPerson": {},
+ "sdm.devices.traits.CameraSound": {},
+ "sdm.devices.traits.CameraMotion": {},
+ "sdm.devices.traits.CameraEventImage": {},
+ "sdm.devices.traits.DoorbellChime": {}
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/doorbell-room-id",
+ "displayName": "Doorbell Room Name"
+ }
+ ]
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.CameraLiveStream.ExtendRtspStream",
+ "params": {
+ "streamExtensionToken": "CjY5Y3VKaTZwR3o4Y19YbTVfMF..."
+ }
+}
--- /dev/null
+{
+ "results": {
+ "streamExtensionToken": "dGNUlTU2CjY5Y3VKaTZwR3o4Y1...",
+ "streamToken": "g.0.newStreamingToken",
+ "expiresAt": "2018-01-04T18:30:00.000Z"
+ }
+}
--- /dev/null
+{
+ "error": {
+ "code": 400,
+ "message": "Thermostat fan unavailable.",
+ "status": "FAILED_PRECONDITION"
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.CameraEventImage.GenerateImage",
+ "params": {
+ "eventId": "FWWVQVUdGNUlTU2V4MGV2aTNXV..."
+ }
+}
--- /dev/null
+{
+ "results": {
+ "url": "https://domain/sdm_resource/dGNUlTU2CjY5Y3VKaTZwR3o4Y1...",
+ "token": "g.0.eventToken"
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.CameraLiveStream.GenerateRtspStream",
+ "params": {}
+}
--- /dev/null
+{
+ "results": {
+ "streamUrls": {
+ "rtspUrl": "rtsps://someurl.com/CjY5Y3VKaTZwR3o4Y19YbTVfMF...?auth=g.0.streamingToken"
+ },
+ "streamExtensionToken": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...",
+ "streamToken": "g.0.streamingToken",
+ "expiresAt": "2018-01-04T18:30:00.000Z"
+ }
+}
--- /dev/null
+{
+ "devices": [
+ {
+ "name": "enterprises/project-id/devices/thermostat-device-id",
+ "type": "sdm.devices.types.THERMOSTAT",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/thermostat-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.Humidity": {
+ "ambientHumidityPercent": 26
+ },
+ "sdm.devices.traits.Connectivity": {
+ "status": "ONLINE"
+ },
+ "sdm.devices.traits.Fan" : {
+ "timerMode" : "ON",
+ "timerTimeout" : "2019-05-10T03:22:54Z"
+ },
+ "sdm.devices.traits.ThermostatMode": {
+ "mode": "HEAT",
+ "availableModes": [
+ "HEAT",
+ "OFF"
+ ]
+ },
+ "sdm.devices.traits.ThermostatEco": {
+ "availableModes": [
+ "OFF",
+ "MANUAL_ECO"
+ ],
+ "mode": "OFF",
+ "heatCelsius": 15.34473,
+ "coolCelsius": 24.44443
+ },
+ "sdm.devices.traits.ThermostatHvac": {
+ "status": "OFF"
+ },
+ "sdm.devices.traits.Settings": {
+ "temperatureScale": "CELSIUS"
+ },
+ "sdm.devices.traits.ThermostatTemperatureSetpoint": {
+ "heatCelsius": 14.92249
+ },
+ "sdm.devices.traits.Temperature": {
+ "ambientTemperatureCelsius": 19.73
+ }
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/thermostat-room-id",
+ "displayName": "Thermostat Room Name"
+ }
+ ]
+ },
+ {
+ "name": "enterprises/project-id/devices/camera-device-id",
+ "type": "sdm.devices.types.CAMERA",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/camera-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.CameraLiveStream": {
+ "maxVideoResolution": {
+ "width": 640,
+ "height": 480
+ },
+ "videoCodecs": [
+ "H264"
+ ],
+ "audioCodecs": [
+ "AAC"
+ ]
+ },
+ "sdm.devices.traits.CameraImage": {
+ "maxImageResolution": {
+ "width": 1920,
+ "height": 1200
+ }
+ },
+ "sdm.devices.traits.CameraPerson": {},
+ "sdm.devices.traits.CameraSound": {},
+ "sdm.devices.traits.CameraMotion": {},
+ "sdm.devices.traits.CameraEventImage": {}
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/camera-room-id",
+ "displayName": "Camera Room Name"
+ }
+ ]
+ },
+ {
+ "name": "enterprises/project-id/devices/display-device-id",
+ "type": "sdm.devices.types.DISPLAY",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/display-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.CameraLiveStream": {
+ "maxVideoResolution": {
+ "width": 640,
+ "height": 480
+ },
+ "videoCodecs": [
+ "H264"
+ ],
+ "audioCodecs": [
+ "AAC"
+ ]
+ },
+ "sdm.devices.traits.CameraImage": {
+ "maxImageResolution": {
+ "width": 1920,
+ "height": 1200
+ }
+ },
+ "sdm.devices.traits.CameraPerson": {},
+ "sdm.devices.traits.CameraSound": {},
+ "sdm.devices.traits.CameraMotion": {},
+ "sdm.devices.traits.CameraEventImage": {}
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/display-room-id",
+ "displayName": "Display Room Name"
+ }
+ ]
+ },
+ {
+ "name": "enterprises/project-id/devices/doorbell-device-id",
+ "type": "sdm.devices.types.DOORBELL",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/doorbell-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.CameraLiveStream": {
+ "maxVideoResolution": {
+ "width": 640,
+ "height": 480
+ },
+ "videoCodecs": [
+ "H264"
+ ],
+ "audioCodecs": [
+ "AAC"
+ ]
+ },
+ "sdm.devices.traits.CameraImage": {
+ "maxImageResolution": {
+ "width": 1920,
+ "height": 1200
+ }
+ },
+ "sdm.devices.traits.CameraPerson": {},
+ "sdm.devices.traits.CameraSound": {},
+ "sdm.devices.traits.CameraMotion": {},
+ "sdm.devices.traits.CameraEventImage": {},
+ "sdm.devices.traits.DoorbellChime": {}
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/doorbell-room-id",
+ "displayName": "Doorbell Room Name"
+ }
+ ]
+ }
+ ]
+}
--- /dev/null
+{
+ "rooms": [
+ {
+ "name": "enterprises/project-id/structures/structure-id/rooms/kitchen-room-id",
+ "traits": {
+ "sdm.structures.traits.RoomInfo": {
+ "customName": "Kitchen"
+ }
+ }
+ },
+ {
+ "name": "enterprises/project-id/structures/structure-id/rooms/living-room-id",
+ "traits": {
+ "sdm.structures.traits.RoomInfo": {
+ "customName": "Living"
+ }
+ }
+ }
+ ]
+}
--- /dev/null
+{
+ "structures": [
+ {
+ "name": "enterprises/project-id/structures/beach-house-structure-id",
+ "traits": {
+ "sdm.structures.traits.Info": {
+ "customName": "Beach House"
+ }
+ }
+ },
+ {
+ "name": "enterprises/project-id/structures/home-structure-id",
+ "traits": {
+ "sdm.structures.traits.Info": {
+ "customName": "Home"
+ }
+ }
+ }
+ ]
+}
--- /dev/null
+{
+ "error": {
+ "code": 404,
+ "message": "Device enterprises/project-id/devices/device-id not found.",
+ "status": "NOT_FOUND"
+ }
+}
--- /dev/null
+{
+ "maxMessages": 123
+}
--- /dev/null
+{
+ "receivedMessages": [
+ {
+ "ackId": "AID1",
+ "message": {
+ "data": "ZGF0YTE=",
+ "messageId": "1000000000000001",
+ "publishTime": "2021-01-01T01:00:00.000Z"
+ }
+ },
+ {
+ "ackId": "AID2",
+ "message": {
+ "data": "ZGF0YTI=",
+ "messageId": "2000000000000002",
+ "publishTime": "2021-02-02T02:00:00.000Z"
+ }
+ },
+ {
+ "ackId": "AID3",
+ "message": {
+ "data": "ZGF0YTM=",
+ "messageId": "3000000000000003",
+ "publishTime": "2021-03-03T03:00:00.000Z"
+ }
+ }
+ ]
+}
--- /dev/null
+{
+ "eventId": "0120ecc7-3b57-4eb4-9941-91609f189fb4",
+ "timestamp": "2019-01-01T00:00:01Z",
+ "relationUpdate": {
+ "type": "CREATED",
+ "subject": "enterprises/project-id/structures/structure-id",
+ "object": "enterprises/project-id/devices/device-id"
+ },
+ "userId": "AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"
+}
--- /dev/null
+{
+ "eventId": "0120ecc7-3b57-4eb4-9941-91609f189fb4",
+ "timestamp": "2019-01-01T00:00:01Z",
+ "relationUpdate": {
+ "type": "DELETED",
+ "subject": "enterprises/project-id/structures/structure-id",
+ "object": "enterprises/project-id/devices/device-id"
+ },
+ "userId": "AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"
+}
--- /dev/null
+{
+ "eventId": "0120ecc7-3b57-4eb4-9941-91609f189fb4",
+ "timestamp": "2019-01-01T00:00:01Z",
+ "relationUpdate": {
+ "type": "UPDATED",
+ "subject": "enterprises/project-id/structures/structure-id",
+ "object": "enterprises/project-id/devices/device-id"
+ },
+ "userId": "AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi"
+}
--- /dev/null
+{
+ "eventId": "053a5f98-8c9d-426e-acf1-6b8660558832",
+ "timestamp": "2019-01-01T00:00:01Z",
+ "resourceUpdate": {
+ "name": "enterprises/project-id/devices/device-id",
+ "events": {
+ "sdm.devices.events.CameraMotion.Motion": {
+ "eventSessionId": "ESI1",
+ "eventId": "EID1"
+ },
+ "sdm.devices.events.CameraPerson.Person": {
+ "eventSessionId": "ESI2",
+ "eventId": "EID2"
+ },
+ "sdm.devices.events.CameraSound.Sound" : {
+ "eventSessionId" : "ESI3",
+ "eventId" : "EID3"
+ },
+ "sdm.devices.events.DoorbellChime.Chime" : {
+ "eventSessionId" : "ESI4",
+ "eventId" : "EID4"
+ }
+ },
+ "traits": {
+ "sdm.devices.traits.Temperature": {
+ "ambientTemperatureCelsius": 19.73
+ },
+ "sdm.devices.traits.ThermostatHvac": {
+ "status": "OFF"
+ },
+ "sdm.devices.traits.ThermostatTemperatureSetpoint": {
+ "heatCelsius": 14.92249
+ }
+ }
+ },
+ "userId": "AVPHwEuBfnPOnTqzVFT4IONX2Qqhu9EJ4ubO-bNnQ-yi",
+ "resourceGroup": ["enterprises/project-id/devices/device-id"]
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.Fan.SetTimer",
+ "params": {
+ "timerMode": "ON",
+ "duration": "3600s"
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.Fan.SetTimer",
+ "params": {
+ "timerMode": "ON"
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool",
+ "params": {
+ "coolCelsius": 20.0
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.ThermostatEco.SetMode",
+ "params": {
+ "mode": "MANUAL_ECO"
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat",
+ "params": {
+ "heatCelsius": 15.0
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.ThermostatMode.SetMode",
+ "params": {
+ "mode": "HEATCOOL"
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange",
+ "params": {
+ "heatCelsius": 15.0,
+ "coolCelsius": 20.0
+ }
+}
--- /dev/null
+{
+ "command": "sdm.devices.commands.CameraLiveStream.StopRtspStream",
+ "params": {
+ "streamExtensionToken": "CjY5Y3VKaTZwR3o4Y19YbTVfMF..."
+ }
+}
--- /dev/null
+{
+ "name": "enterprises/project-id/devices/thermostat-device-id",
+ "type": "sdm.devices.types.THERMOSTAT",
+ "assignee": "enterprises/project-id/structures/structure-id/rooms/thermostat-room-id",
+ "traits": {
+ "sdm.devices.traits.Info": {
+ "customName": ""
+ },
+ "sdm.devices.traits.Humidity": {
+ "ambientHumidityPercent": 26
+ },
+ "sdm.devices.traits.Connectivity": {
+ "status": "ONLINE"
+ },
+ "sdm.devices.traits.Fan" : {
+ "timerMode" : "ON",
+ "timerTimeout" : "2019-05-10T03:22:54Z"
+ },
+ "sdm.devices.traits.ThermostatMode": {
+ "mode": "HEAT",
+ "availableModes": [
+ "HEAT",
+ "OFF"
+ ]
+ },
+ "sdm.devices.traits.ThermostatEco": {
+ "availableModes": [
+ "OFF",
+ "MANUAL_ECO"
+ ],
+ "mode": "OFF",
+ "heatCelsius": 15.34473,
+ "coolCelsius": 24.44443
+ },
+ "sdm.devices.traits.ThermostatHvac": {
+ "status": "OFF"
+ },
+ "sdm.devices.traits.Settings": {
+ "temperatureScale": "CELSIUS"
+ },
+ "sdm.devices.traits.ThermostatTemperatureSetpoint": {
+ "heatCelsius": 14.92249
+ },
+ "sdm.devices.traits.Temperature": {
+ "ambientTemperatureCelsius": 19.73
+ }
+ },
+ "parentRelations": [
+ {
+ "parent": "enterprises/project-id/structures/structure-id/rooms/thermostat-room-id",
+ "displayName": "Thermostat Room Name"
+ }
+ ]
+}
org.openhab.binding.nest;version='[3.1.0,3.1.1)',\
org.openhab.binding.nest.tests;version='[3.1.0,3.1.1)',\
org.openhab.core;version='[3.1.0,3.1.1)',\
+ org.openhab.core.auth.oauth2client;version='[3.1.0,3.1.1)',\
org.openhab.core.binding.xml;version='[3.1.0,3.1.1)',\
org.openhab.core.config.core;version='[3.1.0,3.1.1)',\
org.openhab.core.config.discovery;version='[3.1.0,3.1.1)',\
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.handler;
-
-import static org.hamcrest.CoreMatchers.*;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.*;
-
-import javax.ws.rs.client.ClientBuilder;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.openhab.binding.nest.internal.config.NestBridgeConfiguration;
-import org.openhab.binding.nest.internal.handler.NestBridgeHandler;
-import org.openhab.binding.nest.internal.handler.NestRedirectUrlSupplier;
-import org.openhab.binding.nest.test.NestTestBridgeHandler;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusInfo;
-import org.openhab.core.thing.binding.ThingHandler;
-import org.openhab.core.thing.binding.ThingHandlerCallback;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-
-/**
- * Tests cases for {@link NestBridgeHandler}.
- *
- * @author David Bennett - Initial contribution
- */
-@ExtendWith(MockitoExtension.class)
-public class NestBridgeHandlerTest {
-
- private ThingHandler handler;
-
- private @Mock Bridge bridge;
- private @Mock ThingHandlerCallback callback;
- private @Mock ClientBuilder clientBuilder;
- private @Mock Configuration configuration;
- private @Mock SseEventSourceFactory eventSourceFactory;
- private @Mock NestRedirectUrlSupplier redirectUrlSupplier;
-
- @BeforeEach
- public void beforeEach() {
- handler = new NestTestBridgeHandler(bridge, clientBuilder, eventSourceFactory, "http://localhost");
- handler.setCallback(callback);
- }
-
- @SuppressWarnings("null")
- @Test
- public void initializeShouldCallTheCallback() {
- when(bridge.getConfiguration()).thenReturn(configuration);
- NestBridgeConfiguration bridgeConfig = new NestBridgeConfiguration();
- when(configuration.as(eq(NestBridgeConfiguration.class))).thenReturn(bridgeConfig);
- bridgeConfig.accessToken = "my token";
-
- // we expect the handler#initialize method to call the callback during execution and
- // pass it the thing and a ThingStatusInfo object containing the ThingStatus of the thing.
- handler.initialize();
-
- // the argument captor will capture the argument of type ThingStatusInfo given to the
- // callback#statusUpdated method.
- ArgumentCaptor<ThingStatusInfo> statusInfoCaptor = ArgumentCaptor.forClass(ThingStatusInfo.class);
-
- // verify the interaction with the callback and capture the ThingStatusInfo argument:
- verify(callback).statusUpdated(eq(bridge), statusInfoCaptor.capture());
- // assert that the ThingStatusInfo given to the callback was build with the UNKNOWN status:
- ThingStatusInfo thingStatusInfo = statusInfoCaptor.getValue();
- assertThat(thingStatusInfo.getStatus(), is(equalTo(ThingStatus.UNKNOWN)));
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.handler;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.core.Is.is;
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.binding.nest.internal.data.NestDataUtil.*;
-import static org.openhab.core.library.types.OnOffType.*;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
-import org.junit.jupiter.api.Test;
-import org.openhab.binding.nest.internal.config.NestDeviceConfiguration;
-import org.openhab.binding.nest.internal.handler.NestCameraHandler;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.builder.ThingBuilder;
-
-/**
- * Tests for {@link NestCameraHandler}.
- *
- * @author Wouter Born - Increase test coverage
- */
-public class NestCameraHandlerTest extends NestThingHandlerOSGiTest {
-
- private static final ThingUID CAMERA_UID = new ThingUID(THING_TYPE_CAMERA, "camera1");
- private static final int CHANNEL_COUNT = 20;
-
- public NestCameraHandlerTest() {
- super(NestCameraHandler.class);
- }
-
- @Override
- protected Thing buildThing(Bridge bridge) {
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestDeviceConfiguration.DEVICE_ID, CAMERA1_DEVICE_ID);
-
- return ThingBuilder.create(THING_TYPE_CAMERA, CAMERA_UID).withLabel("Test Camera").withBridge(bridge.getUID())
- .withChannels(buildChannels(THING_TYPE_CAMERA, CAMERA_UID))
- .withConfiguration(new Configuration(properties)).build();
- }
-
- @Test
- public void completeCameraUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- // Camera channel group
- assertThatItemHasState(CHANNEL_CAMERA_APP_URL, new StringType("https://camera_app_url"));
- assertThatItemHasState(CHANNEL_CAMERA_AUDIO_INPUT_ENABLED, ON);
- assertThatItemHasState(CHANNEL_CAMERA_LAST_ONLINE_CHANGE, parseDateTimeType("2017-01-22T08:19:20.000Z"));
- assertThatItemHasState(CHANNEL_CAMERA_PUBLIC_SHARE_ENABLED, OFF);
- assertThatItemHasState(CHANNEL_CAMERA_PUBLIC_SHARE_URL, new StringType("https://camera_public_share_url"));
- assertThatItemHasState(CHANNEL_CAMERA_SNAPSHOT_URL, new StringType("https://camera_snapshot_url"));
- assertThatItemHasState(CHANNEL_CAMERA_STREAMING, OFF);
- assertThatItemHasState(CHANNEL_CAMERA_VIDEO_HISTORY_ENABLED, OFF);
- assertThatItemHasState(CHANNEL_CAMERA_WEB_URL, new StringType("https://camera_web_url"));
-
- // Last event channel group
- assertThatItemHasState(CHANNEL_LAST_EVENT_ACTIVITY_ZONES, new StringType("id1,id2"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_ANIMATED_IMAGE_URL,
- new StringType("https://last_event_animated_image_url"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_APP_URL, new StringType("https://last_event_app_url"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_END_TIME, parseDateTimeType("2017-01-22T07:40:38.680Z"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_HAS_MOTION, ON);
- assertThatItemHasState(CHANNEL_LAST_EVENT_HAS_PERSON, OFF);
- assertThatItemHasState(CHANNEL_LAST_EVENT_HAS_SOUND, OFF);
- assertThatItemHasState(CHANNEL_LAST_EVENT_IMAGE_URL, new StringType("https://last_event_image_url"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_START_TIME, parseDateTimeType("2017-01-22T07:40:19.020Z"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_URLS_EXPIRE_TIME, parseDateTimeType("2017-02-05T07:40:19.020Z"));
- assertThatItemHasState(CHANNEL_LAST_EVENT_WEB_URL, new StringType("https://last_event_web_url"));
-
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void incompleteCameraUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.UNKNOWN)));
- assertThatAllItemStatesAreNull();
- }
-
- @Test
- public void cameraGone() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
- assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
- }
-
- @Test
- public void channelRefresh() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- updateAllItemStatesToNull();
- assertThatAllItemStatesAreNull();
-
- refreshAllChannels();
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void handleStreamingCommands() throws IOException {
- handleCommand(CHANNEL_CAMERA_STREAMING, ON);
- assertNestApiPropertyState(CAMERA1_DEVICE_ID, "is_streaming", "true");
-
- handleCommand(CHANNEL_CAMERA_STREAMING, OFF);
- assertNestApiPropertyState(CAMERA1_DEVICE_ID, "is_streaming", "false");
-
- handleCommand(CHANNEL_CAMERA_STREAMING, ON);
- assertNestApiPropertyState(CAMERA1_DEVICE_ID, "is_streaming", "true");
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.handler;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.core.Is.is;
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.binding.nest.internal.data.NestDataUtil.*;
-import static org.openhab.core.library.types.OnOffType.OFF;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
-import org.junit.jupiter.api.Test;
-import org.openhab.binding.nest.internal.config.NestDeviceConfiguration;
-import org.openhab.binding.nest.internal.handler.NestSmokeDetectorHandler;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.builder.ThingBuilder;
-
-/**
- * Tests for {@link NestSmokeDetectorHandler}.
- *
- * @author Wouter Born - Increase test coverage
- */
-public class NestSmokeDetectorHandlerTest extends NestThingHandlerOSGiTest {
-
- private static final ThingUID SMOKE_DETECTOR_UID = new ThingUID(THING_TYPE_SMOKE_DETECTOR, "smoke1");
- private static final int CHANNEL_COUNT = 7;
-
- public NestSmokeDetectorHandlerTest() {
- super(NestSmokeDetectorHandler.class);
- }
-
- @Override
- protected Thing buildThing(Bridge bridge) {
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestDeviceConfiguration.DEVICE_ID, SMOKE1_DEVICE_ID);
-
- return ThingBuilder.create(THING_TYPE_SMOKE_DETECTOR, SMOKE_DETECTOR_UID).withLabel("Test Smoke Detector")
- .withBridge(bridge.getUID()).withChannels(buildChannels(THING_TYPE_SMOKE_DETECTOR, SMOKE_DETECTOR_UID))
- .withConfiguration(new Configuration(properties)).build();
- }
-
- @Test
- public void completeSmokeDetectorUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- assertThatItemHasState(CHANNEL_CO_ALARM_STATE, new StringType("OK"));
- assertThatItemHasState(CHANNEL_LAST_CONNECTION, parseDateTimeType("2017-02-02T20:53:05.338Z"));
- assertThatItemHasState(CHANNEL_LAST_MANUAL_TEST_TIME, parseDateTimeType("2016-10-31T23:59:59.000Z"));
- assertThatItemHasState(CHANNEL_LOW_BATTERY, OFF);
- assertThatItemHasState(CHANNEL_MANUAL_TEST_ACTIVE, OFF);
- assertThatItemHasState(CHANNEL_SMOKE_ALARM_STATE, new StringType("OK"));
- assertThatItemHasState(CHANNEL_UI_COLOR_STATE, new StringType("GREEN"));
-
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void incompleteSmokeDetectorUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.UNKNOWN)));
- assertThatAllItemStatesAreNull();
- }
-
- @Test
- public void smokeDetectorGone() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
- assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
- }
-
- @Test
- public void channelRefresh() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- updateAllItemStatesToNull();
- assertThatAllItemStatesAreNull();
-
- refreshAllChannels();
- assertThatAllItemStatesAreNotNull();
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.handler;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.core.Is.is;
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.binding.nest.internal.data.NestDataUtil.*;
-import static org.openhab.core.library.types.OnOffType.OFF;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
-import org.junit.jupiter.api.Test;
-import org.openhab.binding.nest.internal.config.NestStructureConfiguration;
-import org.openhab.binding.nest.internal.handler.NestStructureHandler;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.builder.ThingBuilder;
-
-/**
- * Tests for {@link NestStructureHandler}.
- *
- * @author Wouter Born - Increase test coverage
- */
-public class NestStructureHandlerTest extends NestThingHandlerOSGiTest {
-
- private static final ThingUID STRUCTURE_UID = new ThingUID(THING_TYPE_STRUCTURE, "structure1");
- private static final int CHANNEL_COUNT = 11;
-
- public NestStructureHandlerTest() {
- super(NestStructureHandler.class);
- }
-
- @Override
- protected Thing buildThing(Bridge bridge) {
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestStructureConfiguration.STRUCTURE_ID, STRUCTURE1_STRUCTURE_ID);
-
- return ThingBuilder.create(THING_TYPE_STRUCTURE, STRUCTURE_UID).withLabel("Test Structure")
- .withBridge(bridge.getUID()).withChannels(buildChannels(THING_TYPE_STRUCTURE, STRUCTURE_UID))
- .withConfiguration(new Configuration(properties)).build();
- }
-
- @Test
- public void completeStructureUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- assertThatItemHasState(CHANNEL_AWAY, new StringType("HOME"));
- assertThatItemHasState(CHANNEL_CO_ALARM_STATE, new StringType("OK"));
- assertThatItemHasState(CHANNEL_COUNTRY_CODE, new StringType("US"));
- assertThatItemHasState(CHANNEL_ETA_BEGIN, parseDateTimeType("2017-02-02T03:10:08.000Z"));
- assertThatItemHasState(CHANNEL_PEAK_PERIOD_END_TIME, parseDateTimeType("2017-07-01T01:03:08.400Z"));
- assertThatItemHasState(CHANNEL_PEAK_PERIOD_START_TIME, parseDateTimeType("2017-06-01T13:31:10.870Z"));
- assertThatItemHasState(CHANNEL_POSTAL_CODE, new StringType("98056"));
- assertThatItemHasState(CHANNEL_RUSH_HOUR_REWARDS_ENROLLMENT, OFF);
- assertThatItemHasState(CHANNEL_SECURITY_STATE, new StringType("OK"));
- assertThatItemHasState(CHANNEL_SMOKE_ALARM_STATE, new StringType("OK"));
- assertThatItemHasState(CHANNEL_TIME_ZONE, new StringType("America/Los_Angeles"));
-
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void incompleteStructureUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNull();
- }
-
- @Test
- public void structureGone() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
- assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
- }
-
- @Test
- public void channelRefresh() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- updateAllItemStatesToNull();
- assertThatAllItemStatesAreNull();
-
- refreshAllChannels();
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void handleAwayCommands() throws IOException {
- handleCommand(CHANNEL_AWAY, new StringType("AWAY"));
- assertNestApiPropertyState(STRUCTURE1_STRUCTURE_ID, "away", "away");
-
- handleCommand(CHANNEL_AWAY, new StringType("HOME"));
- assertNestApiPropertyState(STRUCTURE1_STRUCTURE_ID, "away", "home");
-
- handleCommand(CHANNEL_AWAY, new StringType("AWAY"));
- assertNestApiPropertyState(STRUCTURE1_STRUCTURE_ID, "away", "away");
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.handler;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.core.Is.is;
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.binding.nest.internal.data.NestDataUtil.*;
-import static org.openhab.core.library.types.OnOffType.*;
-import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
-import static org.openhab.core.library.unit.SIUnits.CELSIUS;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
-import org.junit.jupiter.api.Test;
-import org.openhab.binding.nest.internal.config.NestDeviceConfiguration;
-import org.openhab.binding.nest.internal.handler.NestThermostatHandler;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.library.types.QuantityType;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.library.unit.Units;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.builder.ThingBuilder;
-
-/**
- * Tests for {@link NestThermostatHandler}.
- *
- * @author Wouter Born - Increase test coverage
- */
-public class NestThermostatHandlerTest extends NestThingHandlerOSGiTest {
-
- private static final ThingUID THERMOSTAT_UID = new ThingUID(THING_TYPE_THERMOSTAT, "thermostat1");
- private static final int CHANNEL_COUNT = 25;
-
- public NestThermostatHandlerTest() {
- super(NestThermostatHandler.class);
- }
-
- @Override
- protected Thing buildThing(Bridge bridge) {
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestDeviceConfiguration.DEVICE_ID, THERMOSTAT1_DEVICE_ID);
-
- return ThingBuilder.create(THING_TYPE_THERMOSTAT, THERMOSTAT_UID).withLabel("Test Thermostat")
- .withBridge(bridge.getUID()).withChannels(buildChannels(THING_TYPE_THERMOSTAT, THERMOSTAT_UID))
- .withConfiguration(new Configuration(properties)).build();
- }
-
- @Test
- public void completeThermostatCelsiusUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, CELSIUS));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- assertThatItemHasState(CHANNEL_CAN_COOL, OFF);
- assertThatItemHasState(CHANNEL_CAN_HEAT, ON);
- assertThatItemHasState(CHANNEL_ECO_MAX_SET_POINT, new QuantityType<>(24, CELSIUS));
- assertThatItemHasState(CHANNEL_ECO_MIN_SET_POINT, new QuantityType<>(12.5, CELSIUS));
- assertThatItemHasState(CHANNEL_FAN_TIMER_ACTIVE, OFF);
- assertThatItemHasState(CHANNEL_FAN_TIMER_DURATION, new QuantityType<>(15, Units.MINUTE));
- assertThatItemHasState(CHANNEL_FAN_TIMER_TIMEOUT, parseDateTimeType("1970-01-01T00:00:00.000Z"));
- assertThatItemHasState(CHANNEL_HAS_FAN, ON);
- assertThatItemHasState(CHANNEL_HAS_LEAF, ON);
- assertThatItemHasState(CHANNEL_HUMIDITY, new QuantityType<>(25, Units.PERCENT));
- assertThatItemHasState(CHANNEL_LAST_CONNECTION, parseDateTimeType("2017-02-02T21:00:06.000Z"));
- assertThatItemHasState(CHANNEL_LOCKED, OFF);
- assertThatItemHasState(CHANNEL_LOCKED_MAX_SET_POINT, new QuantityType<>(22, CELSIUS));
- assertThatItemHasState(CHANNEL_LOCKED_MIN_SET_POINT, new QuantityType<>(20, CELSIUS));
- assertThatItemHasState(CHANNEL_MAX_SET_POINT, new QuantityType<>(24, CELSIUS));
- assertThatItemHasState(CHANNEL_MIN_SET_POINT, new QuantityType<>(20, CELSIUS));
- assertThatItemHasState(CHANNEL_MODE, new StringType("HEAT"));
- assertThatItemHasState(CHANNEL_PREVIOUS_MODE, new StringType("HEAT"));
- assertThatItemHasState(CHANNEL_SET_POINT, new QuantityType<>(15.5, CELSIUS));
- assertThatItemHasState(CHANNEL_STATE, new StringType("OFF"));
- assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ACTIVE, OFF);
- assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ENABLED, ON);
- assertThatItemHasState(CHANNEL_TEMPERATURE, new QuantityType<>(19, CELSIUS));
- assertThatItemHasState(CHANNEL_TIME_TO_TARGET, new QuantityType<>(0, Units.MINUTE));
- assertThatItemHasState(CHANNEL_USING_EMERGENCY_HEAT, OFF);
-
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void completeThermostatFahrenheitUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, FAHRENHEIT));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- assertThatItemHasState(CHANNEL_CAN_COOL, OFF);
- assertThatItemHasState(CHANNEL_CAN_HEAT, ON);
- assertThatItemHasState(CHANNEL_ECO_MAX_SET_POINT, new QuantityType<>(76, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_ECO_MIN_SET_POINT, new QuantityType<>(55, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_FAN_TIMER_ACTIVE, OFF);
- assertThatItemHasState(CHANNEL_FAN_TIMER_DURATION, new QuantityType<>(15, Units.MINUTE));
- assertThatItemHasState(CHANNEL_FAN_TIMER_TIMEOUT, parseDateTimeType("1970-01-01T00:00:00.000Z"));
- assertThatItemHasState(CHANNEL_HAS_FAN, ON);
- assertThatItemHasState(CHANNEL_HAS_LEAF, ON);
- assertThatItemHasState(CHANNEL_HUMIDITY, new QuantityType<>(25, Units.PERCENT));
- assertThatItemHasState(CHANNEL_LAST_CONNECTION, parseDateTimeType("2017-02-02T21:00:06.000Z"));
- assertThatItemHasState(CHANNEL_LOCKED, OFF);
- assertThatItemHasState(CHANNEL_LOCKED_MAX_SET_POINT, new QuantityType<>(72, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_LOCKED_MIN_SET_POINT, new QuantityType<>(68, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_MAX_SET_POINT, new QuantityType<>(75, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_MIN_SET_POINT, new QuantityType<>(68, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_MODE, new StringType("HEAT"));
- assertThatItemHasState(CHANNEL_PREVIOUS_MODE, new StringType("HEAT"));
- assertThatItemHasState(CHANNEL_SET_POINT, new QuantityType<>(60, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_STATE, new StringType("OFF"));
- assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ACTIVE, OFF);
- assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ENABLED, ON);
- assertThatItemHasState(CHANNEL_TEMPERATURE, new QuantityType<>(66, FAHRENHEIT));
- assertThatItemHasState(CHANNEL_TIME_TO_TARGET, new QuantityType<>(0, Units.MINUTE));
- assertThatItemHasState(CHANNEL_USING_EMERGENCY_HEAT, OFF);
-
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void incompleteThermostatUpdate() throws IOException {
- assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
- assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
-
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.UNKNOWN)));
- assertThatAllItemStatesAreNull();
- }
-
- @Test
- public void thermostatGone() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
- assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
- }
-
- @Test
- public void channelRefresh() throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
- assertThatAllItemStatesAreNotNull();
-
- updateAllItemStatesToNull();
- assertThatAllItemStatesAreNull();
-
- refreshAllChannels();
- assertThatAllItemStatesAreNotNull();
- }
-
- @Test
- public void handleFanTimerActiveCommands() throws IOException {
- handleCommand(CHANNEL_FAN_TIMER_ACTIVE, ON);
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_active", "true");
-
- handleCommand(CHANNEL_FAN_TIMER_ACTIVE, OFF);
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_active", "false");
-
- handleCommand(CHANNEL_FAN_TIMER_ACTIVE, ON);
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_active", "true");
- }
-
- @Test
- public void handleFanTimerDurationCommands() throws IOException {
- int[] durations = { 15, 30, 45, 60, 120, 240, 480, 960, 15 };
- for (int duration : durations) {
- handleCommand(CHANNEL_FAN_TIMER_DURATION, new QuantityType<>(duration, Units.MINUTE));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_duration", String.valueOf(duration));
- }
- }
-
- @Test
- public void handleMaxSetPointCelsiusCommands() throws IOException {
- celsiusCommandsTest(CHANNEL_MAX_SET_POINT, "target_temperature_high_c");
- }
-
- @Test
- public void handleMaxSetPointFahrenheitCommands() throws IOException {
- fahrenheitCommandsTest(CHANNEL_MAX_SET_POINT, "target_temperature_high_f");
- }
-
- @Test
- public void handleMinSetPointCelsiusCommands() throws IOException {
- celsiusCommandsTest(CHANNEL_MIN_SET_POINT, "target_temperature_low_c");
- }
-
- @Test
- public void handleMinSetPointFahrenheitCommands() throws IOException {
- fahrenheitCommandsTest(CHANNEL_MIN_SET_POINT, "target_temperature_low_f");
- }
-
- @Test
- public void handleChannelModeCommands() throws IOException {
- handleCommand(CHANNEL_MODE, new StringType("HEAT"));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "heat");
-
- handleCommand(CHANNEL_MODE, new StringType("COOL"));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "cool");
-
- handleCommand(CHANNEL_MODE, new StringType("HEAT_COOL"));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "heat-cool");
-
- handleCommand(CHANNEL_MODE, new StringType("ECO"));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "eco");
-
- handleCommand(CHANNEL_MODE, new StringType("OFF"));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "off");
-
- handleCommand(CHANNEL_MODE, new StringType("HEAT"));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "heat");
- }
-
- @Test
- public void handleSetPointCelsiusCommands() throws IOException {
- celsiusCommandsTest(CHANNEL_SET_POINT, "target_temperature_c");
- }
-
- @Test
- public void handleSetPointFahrenheitCommands() throws IOException {
- fahrenheitCommandsTest(CHANNEL_SET_POINT, "target_temperature_f");
- }
-
- private void celsiusCommandsTest(String channelId, String apiPropertyName) throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, CELSIUS));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- handleCommand(channelId, new QuantityType<>(20, CELSIUS));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "20.0");
-
- handleCommand(channelId, new QuantityType<>(21.123, CELSIUS));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "21.0");
-
- handleCommand(channelId, new QuantityType<>(22.541, CELSIUS));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "22.5");
-
- handleCommand(channelId, new QuantityType<>(23.74, CELSIUS));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "23.5");
-
- handleCommand(channelId, new QuantityType<>(23.75, CELSIUS));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "24.0");
-
- handleCommand(channelId, new QuantityType<>(70, FAHRENHEIT));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "21.0");
- }
-
- private void fahrenheitCommandsTest(String channelId, String apiPropertyName) throws IOException {
- waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
- putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, FAHRENHEIT));
- waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
-
- handleCommand(channelId, new QuantityType<>(70, FAHRENHEIT));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "70");
-
- handleCommand(channelId, new QuantityType<>(71.123, FAHRENHEIT));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "71");
-
- handleCommand(channelId, new QuantityType<>(71.541, FAHRENHEIT));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "72");
-
- handleCommand(channelId, new QuantityType<>(72.74, FAHRENHEIT));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "73");
-
- handleCommand(channelId, new QuantityType<>(73.75, FAHRENHEIT));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "74");
-
- handleCommand(channelId, new QuantityType<>(21, CELSIUS));
- assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "70");
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.handler;
-
-import static org.hamcrest.CoreMatchers.*;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.core.Is.is;
-import static org.hamcrest.core.IsNot.not;
-import static org.mockito.Mockito.*;
-import static org.openhab.binding.nest.internal.rest.NestStreamingRestClient.PUT;
-
-import java.io.IOException;
-import java.time.Instant;
-import java.time.format.DateTimeParseException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.function.Function;
-
-import javax.ws.rs.client.ClientBuilder;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.BeforeEach;
-import org.openhab.binding.nest.internal.config.NestBridgeConfiguration;
-import org.openhab.binding.nest.internal.handler.NestBaseHandler;
-import org.openhab.binding.nest.test.NestTestApiServlet;
-import org.openhab.binding.nest.test.NestTestBridgeHandler;
-import org.openhab.binding.nest.test.NestTestHandlerFactory;
-import org.openhab.binding.nest.test.NestTestServer;
-import org.openhab.core.config.core.Configuration;
-import org.openhab.core.events.EventPublisher;
-import org.openhab.core.items.Item;
-import org.openhab.core.items.ItemFactory;
-import org.openhab.core.items.ItemNotFoundException;
-import org.openhab.core.items.ItemRegistry;
-import org.openhab.core.items.events.ItemEventFactory;
-import org.openhab.core.library.types.DateTimeType;
-import org.openhab.core.test.TestPortUtil;
-import org.openhab.core.test.java.JavaOSGiTest;
-import org.openhab.core.test.storage.VolatileStorageService;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Channel;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.ManagedThingProvider;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingProvider;
-import org.openhab.core.thing.ThingTypeUID;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.ThingHandlerFactory;
-import org.openhab.core.thing.binding.builder.BridgeBuilder;
-import org.openhab.core.thing.binding.builder.ChannelBuilder;
-import org.openhab.core.thing.link.ItemChannelLink;
-import org.openhab.core.thing.link.ManagedItemChannelLinkProvider;
-import org.openhab.core.thing.type.ChannelDefinition;
-import org.openhab.core.thing.type.ChannelGroupDefinition;
-import org.openhab.core.thing.type.ChannelGroupType;
-import org.openhab.core.thing.type.ChannelGroupTypeRegistry;
-import org.openhab.core.thing.type.ChannelType;
-import org.openhab.core.thing.type.ChannelTypeRegistry;
-import org.openhab.core.thing.type.ThingType;
-import org.openhab.core.thing.type.ThingTypeRegistry;
-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.osgi.service.component.ComponentContext;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * {@link NestThingHandlerOSGiTest} is an abstract base class for Nest OSGi based tests.
- *
- * @author Wouter Born - Increase test coverage
- */
-public abstract class NestThingHandlerOSGiTest extends JavaOSGiTest {
-
- private static final String SERVER_HOST = "127.0.0.1";
- private static final int SERVER_PORT = TestPortUtil.findFreePort();
- private static final int SERVER_TIMEOUT = -1;
- private static final String REDIRECT_URL = "http://" + SERVER_HOST + ":" + SERVER_PORT;
-
- private final Logger logger = LoggerFactory.getLogger(NestThingHandlerOSGiTest.class);
-
- private static NestTestServer server;
- private static NestTestApiServlet servlet = new NestTestApiServlet();
-
- private ChannelTypeRegistry channelTypeRegistry;
- private ChannelGroupTypeRegistry channelGroupTypeRegistry;
- private ItemFactory itemFactory;
- private ItemRegistry itemRegistry;
- private EventPublisher eventPublisher;
- private ManagedThingProvider managedThingProvider;
- private ThingTypeRegistry thingTypeRegistry;
- private ManagedItemChannelLinkProvider managedItemChannelLinkProvider;
- private VolatileStorageService volatileStorageService = new VolatileStorageService();
-
- protected Bridge bridge;
- protected NestTestBridgeHandler bridgeHandler;
- protected Thing thing;
- protected NestBaseHandler<?> thingHandler;
- private Class<? extends NestBaseHandler<?>> thingClass;
-
- private NestTestHandlerFactory nestTestHandlerFactory;
- private @NonNullByDefault({}) ClientBuilder clientBuilder;
- private @NonNullByDefault({}) SseEventSourceFactory eventSourceFactory;
-
- public NestThingHandlerOSGiTest(Class<? extends NestBaseHandler<?>> thingClass) {
- this.thingClass = thingClass;
- }
-
- @BeforeAll
- public static void setUpClass() throws Exception {
- ServletHolder holder = new ServletHolder(servlet);
- server = new NestTestServer(SERVER_HOST, SERVER_PORT, SERVER_TIMEOUT, holder);
- server.startServer();
- }
-
- @BeforeEach
- public void setUp() throws ItemNotFoundException {
- registerService(volatileStorageService);
-
- managedThingProvider = getService(ThingProvider.class, ManagedThingProvider.class);
- assertThat("Could not get ManagedThingProvider", managedThingProvider, is(notNullValue()));
-
- thingTypeRegistry = getService(ThingTypeRegistry.class);
- assertThat("Could not get ThingTypeRegistry", thingTypeRegistry, is(notNullValue()));
-
- channelTypeRegistry = getService(ChannelTypeRegistry.class);
- assertThat("Could not get ChannelTypeRegistry", channelTypeRegistry, is(notNullValue()));
-
- channelGroupTypeRegistry = getService(ChannelGroupTypeRegistry.class);
- assertThat("Could not get ChannelGroupTypeRegistry", channelGroupTypeRegistry, is(notNullValue()));
-
- eventPublisher = getService(EventPublisher.class);
- assertThat("Could not get EventPublisher", eventPublisher, is(notNullValue()));
-
- itemFactory = getService(ItemFactory.class);
- assertThat("Could not get ItemFactory", itemFactory, is(notNullValue()));
-
- itemRegistry = getService(ItemRegistry.class);
- assertThat("Could not get ItemRegistry", itemRegistry, is(notNullValue()));
-
- managedItemChannelLinkProvider = getService(ManagedItemChannelLinkProvider.class);
- assertThat("Could not get ManagedItemChannelLinkProvider", managedItemChannelLinkProvider, is(notNullValue()));
-
- clientBuilder = getService(ClientBuilder.class);
- assertThat("Could not get ClientBuilder", clientBuilder, is(notNullValue()));
-
- eventSourceFactory = getService(SseEventSourceFactory.class);
- assertThat("Could not get SseEventSourceFactory", eventSourceFactory, is(notNullValue()));
-
- ComponentContext componentContext = mock(ComponentContext.class);
- when(componentContext.getBundleContext()).thenReturn(bundleContext);
-
- nestTestHandlerFactory = new NestTestHandlerFactory(clientBuilder, eventSourceFactory);
- nestTestHandlerFactory.activate(componentContext,
- Map.of(NestTestHandlerFactory.REDIRECT_URL_CONFIG_PROPERTY, REDIRECT_URL));
- registerService(nestTestHandlerFactory);
-
- nestTestHandlerFactory = getService(ThingHandlerFactory.class, NestTestHandlerFactory.class);
- assertThat("Could not get NestTestHandlerFactory", nestTestHandlerFactory, is(notNullValue()));
-
- bridge = buildBridge();
- thing = buildThing(bridge);
-
- bridgeHandler = addThing(bridge, NestTestBridgeHandler.class);
- thingHandler = addThing(thing, thingClass);
-
- createAndLinkItems();
- assertThatAllItemStatesAreNull();
- }
-
- @AfterEach
- public void tearDown() {
- servlet.reset();
- servlet.closeConnections();
-
- if (thing != null) {
- managedThingProvider.remove(thing.getUID());
- }
- if (bridge != null) {
- managedThingProvider.remove(bridge.getUID());
- }
-
- unregisterService(volatileStorageService);
- }
-
- protected Bridge buildBridge() {
- Map<String, Object> properties = new HashMap<>();
- properties.put(NestBridgeConfiguration.ACCESS_TOKEN,
- "c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc");
- properties.put(NestBridgeConfiguration.PINCODE, "64P2XRYT");
- properties.put(NestBridgeConfiguration.PRODUCT_ID, "8fdf9885-ca07-4252-1aa3-f3d5ca9589e0");
- properties.put(NestBridgeConfiguration.PRODUCT_SECRET, "QITLR3iyUlWaj9dbvCxsCKp4f");
-
- return BridgeBuilder.create(NestTestBridgeHandler.THING_TYPE_TEST_BRIDGE, "test_account")
- .withLabel("Test Account").withConfiguration(new Configuration(properties)).build();
- }
-
- protected abstract Thing buildThing(Bridge bridge);
-
- protected List<Channel> buildChannels(ThingTypeUID thingTypeUID, ThingUID thingUID) {
- waitForAssert(() -> assertThat(thingTypeRegistry.getThingType(thingTypeUID), notNullValue()));
-
- ThingType thingType = thingTypeRegistry.getThingType(thingTypeUID);
-
- List<Channel> channels = new ArrayList<>();
- channels.addAll(buildChannels(thingUID, thingType.getChannelDefinitions(), (id) -> id));
-
- for (ChannelGroupDefinition channelGroupDefinition : thingType.getChannelGroupDefinitions()) {
- ChannelGroupType channelGroupType = channelGroupTypeRegistry
- .getChannelGroupType(channelGroupDefinition.getTypeUID());
- String groupId = channelGroupDefinition.getId();
- if (channelGroupType != null) {
- channels.addAll(
- buildChannels(thingUID, channelGroupType.getChannelDefinitions(), (id) -> groupId + "#" + id));
- }
- }
-
- channels.sort((Channel c1, Channel c2) -> c1.getUID().getId().compareTo(c2.getUID().getId()));
- return channels;
- }
-
- protected List<Channel> buildChannels(ThingUID thingUID, List<ChannelDefinition> channelDefinitions,
- Function<String, String> channelIdFunction) {
- List<Channel> result = new ArrayList<>();
- for (ChannelDefinition channelDefinition : channelDefinitions) {
- ChannelType channelType = channelTypeRegistry.getChannelType(channelDefinition.getChannelTypeUID());
- if (channelType != null) {
- result.add(ChannelBuilder
- .create(new ChannelUID(thingUID, channelIdFunction.apply(channelDefinition.getId())),
- channelType.getItemType())
- .build());
- }
- }
- return result;
- }
-
- @SuppressWarnings("unchecked")
- protected <T> T addThing(Thing thing, Class<T> thingHandlerClass) {
- assertThat(thing.getHandler(), is(nullValue()));
- managedThingProvider.add(thing);
- waitForAssert(() -> assertThat(thing.getHandler(), notNullValue()));
- assertThat(thing.getConfiguration(), is(notNullValue()));
- assertThat(thing.getHandler(), is(instanceOf(thingHandlerClass)));
- return (T) thing.getHandler();
- }
-
- protected String getThingId() {
- return thing.getUID().getId();
- }
-
- protected ThingUID getThingUID() {
- return thing.getUID();
- }
-
- protected void putStreamingEventData(String json) throws IOException {
- String singleLineJson = json.replaceAll("\n\r\\s+", "").replaceAll("\n\\s+", "").replaceAll("\n\r", "")
- .replaceAll("\n", "");
- servlet.queueEvent(PUT, singleLineJson);
- }
-
- protected void createAndLinkItems() {
- thing.getChannels().forEach(c -> {
- String itemName = getItemName(c.getUID().getId());
- Item item = itemFactory.createItem(c.getAcceptedItemType(), itemName);
- if (item != null) {
- itemRegistry.add(item);
- }
- managedItemChannelLinkProvider.add(new ItemChannelLink(itemName, c.getUID()));
- });
- }
-
- protected void assertThatItemHasState(String channelId, State state) {
- waitForAssert(() -> assertThat("Wrong state for item of channel '" + channelId + "' ", getItemState(channelId),
- is(state)));
- }
-
- protected void assertThatItemHasNotState(String channelId, State state) {
- waitForAssert(() -> assertThat("Wrong state for item of channel '" + channelId + "' ", getItemState(channelId),
- is(not(state))));
- }
-
- protected void assertThatAllItemStatesAreNull() {
- thing.getChannels().forEach(c -> assertThatItemHasState(c.getUID().getId(), UnDefType.NULL));
- }
-
- protected void assertThatAllItemStatesAreNotNull() {
- thing.getChannels().forEach(c -> assertThatItemHasNotState(c.getUID().getId(), UnDefType.NULL));
- }
-
- protected ChannelUID getChannelUID(String channelId) {
- return new ChannelUID(getThingUID(), channelId);
- }
-
- protected String getItemName(String channelId) {
- return getThingId() + "_" + channelId.replaceAll("#", "_");
- }
-
- private State getItemState(String channelId) {
- String itemName = getItemName(channelId);
- try {
- return itemRegistry.getItem(itemName).getState();
- } catch (ItemNotFoundException e) {
- throw new AssertionError("Item with name '" + itemName + "' not found");
- }
- }
-
- protected void logItemStates() {
- thing.getChannels().forEach(c -> {
- String channelId = c.getUID().getId();
- String itemName = getItemName(channelId);
- logger.debug("{} = {}", itemName, getItemState(channelId));
- });
- }
-
- protected void updateAllItemStatesToNull() {
- thing.getChannels().forEach(c -> updateItemState(c.getUID().getId(), UnDefType.NULL));
- }
-
- protected void refreshAllChannels() {
- thing.getChannels().forEach(c -> thingHandler.handleCommand(c.getUID(), RefreshType.REFRESH));
- }
-
- protected void handleCommand(String channelId, Command command) {
- thingHandler.handleCommand(getChannelUID(channelId), command);
- }
-
- protected void updateItemState(String channelId, State state) {
- String itemName = getItemName(channelId);
- eventPublisher.post(ItemEventFactory.createStateEvent(itemName, state));
- }
-
- protected void assertNestApiPropertyState(String nestId, String propertyName, String state) {
- waitForAssert(() -> assertThat(servlet.getNestIdPropertyState(nestId, propertyName), is(state)));
- }
-
- public static DateTimeType parseDateTimeType(String text) {
- try {
- return new DateTimeType(Instant.parse(text).atZone(TimeZone.getDefault().toZoneId()));
- } catch (DateTimeParseException e) {
- throw new IllegalArgumentException("Invalid date time argument: " + text, e);
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.openhab.binding.nest.internal.data.NestDataUtil.*;
-
-import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-
-import org.junit.jupiter.api.Test;
-import org.openhab.core.library.unit.SIUnits;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Test cases for gson parsing of model classes
- *
- * @author David Bennett - Initial contribution
- * @author Wouter Born - Increase test coverage
- */
-public class GsonParsingTest {
-
- private final Logger logger = LoggerFactory.getLogger(GsonParsingTest.class);
-
- private static void assertEqualDateTime(String expected, Date actual) {
- SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
- assertEquals(expected, sdf.format(actual));
- }
-
- @Test
- public void verifyCompleteInput() throws IOException {
- TopLevelData topLevel = fromJson("top-level-data.json", TopLevelData.class);
-
- assertEquals(topLevel.getDevices().getThermostats().size(), 1);
- assertNotNull(topLevel.getDevices().getThermostats().get(THERMOSTAT1_DEVICE_ID));
- assertEquals(topLevel.getDevices().getCameras().size(), 2);
- assertNotNull(topLevel.getDevices().getCameras().get(CAMERA1_DEVICE_ID));
- assertNotNull(topLevel.getDevices().getCameras().get(CAMERA2_DEVICE_ID));
- assertEquals(topLevel.getDevices().getSmokeCoAlarms().size(), 4);
- assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE1_DEVICE_ID));
- assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE2_DEVICE_ID));
- assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE3_DEVICE_ID));
- assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE4_DEVICE_ID));
- }
-
- @Test
- public void verifyCompleteStreamingInput() throws IOException {
- TopLevelStreamingData topLevelStreamingData = fromJson("top-level-streaming-data.json",
- TopLevelStreamingData.class);
-
- assertEquals("/", topLevelStreamingData.getPath());
-
- TopLevelData data = topLevelStreamingData.getData();
- assertEquals(data.getDevices().getThermostats().size(), 1);
- assertNotNull(data.getDevices().getThermostats().get(THERMOSTAT1_DEVICE_ID));
- assertEquals(data.getDevices().getCameras().size(), 2);
- assertNotNull(data.getDevices().getCameras().get(CAMERA1_DEVICE_ID));
- assertNotNull(data.getDevices().getCameras().get(CAMERA2_DEVICE_ID));
- assertEquals(data.getDevices().getSmokeCoAlarms().size(), 4);
- assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE1_DEVICE_ID));
- assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE2_DEVICE_ID));
- assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE3_DEVICE_ID));
- assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE4_DEVICE_ID));
- }
-
- @Test
- public void verifyThermostat() throws IOException {
- Thermostat thermostat = fromJson("thermostat-data.json", Thermostat.class);
- logger.debug("Thermostat: {}", thermostat);
-
- assertTrue(thermostat.isOnline());
- assertTrue(thermostat.isCanHeat());
- assertTrue(thermostat.isHasLeaf());
- assertFalse(thermostat.isCanCool());
- assertFalse(thermostat.isFanTimerActive());
- assertFalse(thermostat.isLocked());
- assertFalse(thermostat.isSunlightCorrectionActive());
- assertTrue(thermostat.isSunlightCorrectionEnabled());
- assertFalse(thermostat.isUsingEmergencyHeat());
- assertEquals(THERMOSTAT1_DEVICE_ID, thermostat.getDeviceId());
- assertEquals(Integer.valueOf(15), thermostat.getFanTimerDuration());
- assertEqualDateTime("2017-02-02T21:00:06.000Z", thermostat.getLastConnection());
- assertEqualDateTime("1970-01-01T00:00:00.000Z", thermostat.getFanTimerTimeout());
- assertEquals(Double.valueOf(24.0), thermostat.getEcoTemperatureHigh());
- assertEquals(Double.valueOf(12.5), thermostat.getEcoTemperatureLow());
- assertEquals(Double.valueOf(22.0), thermostat.getLockedTempMax());
- assertEquals(Double.valueOf(20.0), thermostat.getLockedTempMin());
- assertEquals(Thermostat.Mode.HEAT, thermostat.getMode());
- assertEquals("Living Room (Living Room)", thermostat.getName());
- assertEquals("Living Room Thermostat (Living Room)", thermostat.getNameLong());
- assertEquals(null, thermostat.getPreviousHvacMode());
- assertEquals("5.6-7", thermostat.getSoftwareVersion());
- assertEquals(Thermostat.State.OFF, thermostat.getHvacState());
- assertEquals(STRUCTURE1_STRUCTURE_ID, thermostat.getStructureId());
- assertEquals(Double.valueOf(15.5), thermostat.getTargetTemperature());
- assertEquals(Double.valueOf(24.0), thermostat.getTargetTemperatureHigh());
- assertEquals(Double.valueOf(20.0), thermostat.getTargetTemperatureLow());
- assertEquals(SIUnits.CELSIUS, thermostat.getTemperatureUnit());
- assertEquals(Integer.valueOf(0), thermostat.getTimeToTarget());
- assertEquals(THERMOSTAT1_WHERE_ID, thermostat.getWhereId());
- assertEquals("Living Room", thermostat.getWhereName());
- }
-
- @Test
- public void thermostatTimeToTargetSupportedValueParsing() {
- assertEquals((Integer) 0, Thermostat.parseTimeToTarget("~0"));
- assertEquals((Integer) 5, Thermostat.parseTimeToTarget("<5"));
- assertEquals((Integer) 10, Thermostat.parseTimeToTarget("<10"));
- assertEquals((Integer) 15, Thermostat.parseTimeToTarget("~15"));
- assertEquals((Integer) 90, Thermostat.parseTimeToTarget("~90"));
- assertEquals((Integer) 120, Thermostat.parseTimeToTarget(">120"));
- }
-
- @Test
- public void thermostatTimeToTargetUnsupportedValueParsing() {
- assertThrows(NumberFormatException.class, () -> Thermostat.parseTimeToTarget("#5"));
- }
-
- @Test
- public void verifyCamera() throws IOException {
- Camera camera = fromJson("camera-data.json", Camera.class);
- logger.debug("Camera: {}", camera);
-
- assertTrue(camera.isOnline());
- assertEquals("Upstairs", camera.getName());
- assertEquals("Upstairs Camera", camera.getNameLong());
- assertEquals(STRUCTURE1_STRUCTURE_ID, camera.getStructureId());
- assertEquals(CAMERA1_WHERE_ID, camera.getWhereId());
- assertTrue(camera.isAudioInputEnabled());
- assertFalse(camera.isPublicShareEnabled());
- assertFalse(camera.isStreaming());
- assertFalse(camera.isVideoHistoryEnabled());
- assertEquals("https://camera_app_url", camera.getAppUrl());
- assertEquals(CAMERA1_DEVICE_ID, camera.getDeviceId());
- assertNull(camera.getLastConnection());
- assertEqualDateTime("2017-01-22T08:19:20.000Z", camera.getLastIsOnlineChange());
- assertNull(camera.getPublicShareUrl());
- assertEquals("https://camera_snapshot_url", camera.getSnapshotUrl());
- assertEquals("205-600052", camera.getSoftwareVersion());
- assertEquals("https://camera_web_url", camera.getWebUrl());
- assertEquals("https://last_event_animated_image_url", camera.getLastEvent().getAnimatedImageUrl());
- assertEquals(2, camera.getLastEvent().getActivityZones().size());
- assertEquals("id1", camera.getLastEvent().getActivityZones().get(0));
- assertEquals("https://last_event_app_url", camera.getLastEvent().getAppUrl());
- assertEqualDateTime("2017-01-22T07:40:38.680Z", camera.getLastEvent().getEndTime());
- assertEquals("https://last_event_image_url", camera.getLastEvent().getImageUrl());
- assertEqualDateTime("2017-01-22T07:40:19.020Z", camera.getLastEvent().getStartTime());
- assertEqualDateTime("2017-02-05T07:40:19.020Z", camera.getLastEvent().getUrlsExpireTime());
- assertEquals("https://last_event_web_url", camera.getLastEvent().getWebUrl());
- assertTrue(camera.getLastEvent().isHasMotion());
- assertFalse(camera.getLastEvent().isHasPerson());
- assertFalse(camera.getLastEvent().isHasSound());
- }
-
- @Test
- public void verifySmokeDetector() throws IOException {
- SmokeDetector smokeDetector = fromJson("smoke-detector-data.json", SmokeDetector.class);
- logger.debug("SmokeDetector: {}", smokeDetector);
-
- assertTrue(smokeDetector.isOnline());
- assertEquals(SMOKE1_WHERE_ID, smokeDetector.getWhereId());
- assertEquals(SMOKE1_DEVICE_ID, smokeDetector.getDeviceId());
- assertEquals("Downstairs", smokeDetector.getName());
- assertEquals("Downstairs Nest Protect", smokeDetector.getNameLong());
- assertEqualDateTime("2017-02-02T20:53:05.338Z", smokeDetector.getLastConnection());
- assertEquals(SmokeDetector.BatteryHealth.OK, smokeDetector.getBatteryHealth());
- assertEquals(SmokeDetector.AlarmState.OK, smokeDetector.getCoAlarmState());
- assertEquals(SmokeDetector.AlarmState.OK, smokeDetector.getSmokeAlarmState());
- assertEquals("3.1rc9", smokeDetector.getSoftwareVersion());
- assertEquals(STRUCTURE1_STRUCTURE_ID, smokeDetector.getStructureId());
- assertEquals(SmokeDetector.UiColorState.GREEN, smokeDetector.getUiColorState());
- }
-
- @Test
- public void verifyAccessToken() throws IOException {
- AccessTokenData accessToken = fromJson("access-token-data.json", AccessTokenData.class);
- logger.debug("AccessTokenData: {}", accessToken);
-
- assertEquals("access_token", accessToken.getAccessToken());
- assertEquals(Long.valueOf(315360000L), accessToken.getExpiresIn());
- }
-
- @Test
- public void verifyStructure() throws IOException {
- Structure structure = fromJson("structure-data.json", Structure.class);
- logger.debug("Structure: {}", structure);
-
- assertEquals("Home", structure.getName());
- assertEquals("US", structure.getCountryCode());
- assertEquals("98056", structure.getPostalCode());
- assertEquals(Structure.HomeAwayState.HOME, structure.getAway());
- assertEqualDateTime("2017-02-02T03:10:08.000Z", structure.getEtaBegin());
- assertNull(structure.getEta());
- assertNull(structure.getPeakPeriodEndTime());
- assertNull(structure.getPeakPeriodStartTime());
- assertEquals(STRUCTURE1_STRUCTURE_ID, structure.getStructureId());
- assertEquals("America/Los_Angeles", structure.getTimeZone());
- assertFalse(structure.isRhrEnrollment());
- }
-
- @Test
- public void verifyError() throws IOException {
- ErrorData error = fromJson("error-data.json", ErrorData.class);
- logger.debug("ErrorData: {}", error);
-
- assertEquals("blocked", error.getError());
- assertEquals("https://developer.nest.com/documentation/cloud/error-messages#blocked", error.getType());
- assertEquals("blocked", error.getMessage());
- assertEquals("bb514046-edc9-4bca-8239-f7a3cfb0925a", error.getInstance());
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.internal.data;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.UnsupportedEncodingException;
-import java.util.stream.Collectors;
-
-import javax.measure.Unit;
-import javax.measure.quantity.Temperature;
-
-import org.openhab.binding.nest.internal.NestUtils;
-import org.openhab.core.library.unit.ImperialUnits;
-import org.openhab.core.library.unit.SIUnits;
-
-/**
- * Utility class for working with Nest test data in unit tests.
- *
- * @author Wouter Born - Increase test coverage
- */
-public final class NestDataUtil {
-
- public static final String COMPLETE_DATA_FILE_NAME = "top-level-streaming-data.json";
- public static final String INCOMPLETE_DATA_FILE_NAME = "top-level-streaming-data-incomplete.json";
- public static final String EMPTY_DATA_FILE_NAME = "top-level-streaming-data-empty.json";
-
- public static final String CAMERA1_DEVICE_ID = "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ";
- public static final String CAMERA1_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA";
-
- public static final String CAMERA2_DEVICE_ID = "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ";
- public static final String CAMERA2_WHERE_ID = "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ";
-
- public static final String SMOKE1_DEVICE_ID = "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV";
- public static final String SMOKE1_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg";
-
- public static final String SMOKE2_DEVICE_ID = "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV";
- public static final String SMOKE2_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA";
-
- public static final String SMOKE3_DEVICE_ID = "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV";
- public static final String SMOKE3_WHERE_ID = "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ";
-
- public static final String SMOKE4_DEVICE_ID = "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV";
- public static final String SMOKE4_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw";
-
- public static final String STRUCTURE1_STRUCTURE_ID = "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A";
-
- public static final String THERMOSTAT1_DEVICE_ID = "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV";
- public static final String THERMOSTAT1_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw";
-
- private NestDataUtil() {
- // Hidden utility class constructor
- }
-
- public static Reader openDataReader(String fileName) throws UnsupportedEncodingException {
- String packagePath = (NestDataUtil.class.getPackage().getName()).replaceAll("\\.", "/");
- String filePath = "/" + packagePath + "/" + fileName;
- InputStream inputStream = NestDataUtil.class.getClassLoader().getResourceAsStream(filePath);
- return new InputStreamReader(inputStream, "UTF-8");
- }
-
- public static <T> T fromJson(String fileName, Class<T> dataClass) throws IOException {
- try (Reader reader = openDataReader(fileName)) {
- return NestUtils.fromJson(reader, dataClass);
- }
- }
-
- public static String fromFile(String fileName, Unit<Temperature> temperatureUnit) throws IOException {
- String json = fromFile(fileName);
- if (temperatureUnit == SIUnits.CELSIUS) {
- json = json.replace("\"temperature_scale\": \"F\"", "\"temperature_scale\": \"C\"");
- } else if (temperatureUnit == ImperialUnits.FAHRENHEIT) {
- json = json.replace("\"temperature_scale\": \"C\"", "\"temperature_scale\": \"F\"");
- }
- return json;
- }
-
- public static String fromFile(String fileName) throws IOException {
- try (Reader reader = openDataReader(fileName)) {
- return new BufferedReader(reader).lines().parallel().collect(Collectors.joining("\n"));
- }
- }
-}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.util.stream.Collectors;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Temperature;
+
+import org.openhab.binding.nest.internal.wwn.WWNUtils;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.SIUnits;
+
+/**
+ * Utility class for working with Nest test data in unit tests.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public final class WWNDataUtil {
+
+ public static final String COMPLETE_DATA_FILE_NAME = "top-level-streaming-data.json";
+ public static final String INCOMPLETE_DATA_FILE_NAME = "top-level-streaming-data-incomplete.json";
+ public static final String EMPTY_DATA_FILE_NAME = "top-level-streaming-data-empty.json";
+
+ public static final String CAMERA1_DEVICE_ID = "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ";
+ public static final String CAMERA1_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA";
+
+ public static final String CAMERA2_DEVICE_ID = "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ";
+ public static final String CAMERA2_WHERE_ID = "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ";
+
+ public static final String SMOKE1_DEVICE_ID = "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV";
+ public static final String SMOKE1_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg";
+
+ public static final String SMOKE2_DEVICE_ID = "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV";
+ public static final String SMOKE2_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA";
+
+ public static final String SMOKE3_DEVICE_ID = "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV";
+ public static final String SMOKE3_WHERE_ID = "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ";
+
+ public static final String SMOKE4_DEVICE_ID = "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV";
+ public static final String SMOKE4_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw";
+
+ public static final String STRUCTURE1_STRUCTURE_ID = "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A";
+
+ public static final String THERMOSTAT1_DEVICE_ID = "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV";
+ public static final String THERMOSTAT1_WHERE_ID = "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw";
+
+ private WWNDataUtil() {
+ // Hidden utility class constructor
+ }
+
+ public static Reader openDataReader(String fileName) throws UnsupportedEncodingException {
+ String packagePath = (WWNDataUtil.class.getPackage().getName()).replaceAll("\\.", "/");
+ String filePath = "/" + packagePath + "/" + fileName;
+ InputStream inputStream = WWNDataUtil.class.getClassLoader().getResourceAsStream(filePath);
+ return new InputStreamReader(inputStream, "UTF-8");
+ }
+
+ public static <T> T fromJson(String fileName, Class<T> dataClass) throws IOException {
+ try (Reader reader = openDataReader(fileName)) {
+ return WWNUtils.fromJson(reader, dataClass);
+ }
+ }
+
+ public static String fromFile(String fileName, Unit<Temperature> temperatureUnit) throws IOException {
+ String json = fromFile(fileName);
+ if (temperatureUnit == SIUnits.CELSIUS) {
+ json = json.replace("\"temperature_scale\": \"F\"", "\"temperature_scale\": \"C\"");
+ } else if (temperatureUnit == ImperialUnits.FAHRENHEIT) {
+ json = json.replace("\"temperature_scale\": \"C\"", "\"temperature_scale\": \"F\"");
+ }
+ return json;
+ }
+
+ public static String fromFile(String fileName) throws IOException {
+ try (Reader reader = openDataReader(fileName)) {
+ return new BufferedReader(reader).lines().parallel().collect(Collectors.joining("\n"));
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.dto;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.openhab.binding.nest.internal.wwn.dto.WWNDataUtil.*;
+
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import org.junit.jupiter.api.Test;
+import org.openhab.core.library.unit.SIUnits;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Test cases for gson parsing of model classes
+ *
+ * @author David Bennett - Initial contribution
+ * @author Wouter Born - Increase test coverage
+ */
+public class WWNGsonParsingTest {
+
+ private final Logger logger = LoggerFactory.getLogger(WWNGsonParsingTest.class);
+
+ private static void assertEqualDateTime(String expected, Date actual) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
+ assertEquals(expected, sdf.format(actual));
+ }
+
+ @Test
+ public void verifyCompleteInput() throws IOException {
+ WWNTopLevelData topLevel = fromJson("top-level-data.json", WWNTopLevelData.class);
+
+ assertEquals(topLevel.getDevices().getThermostats().size(), 1);
+ assertNotNull(topLevel.getDevices().getThermostats().get(THERMOSTAT1_DEVICE_ID));
+ assertEquals(topLevel.getDevices().getCameras().size(), 2);
+ assertNotNull(topLevel.getDevices().getCameras().get(CAMERA1_DEVICE_ID));
+ assertNotNull(topLevel.getDevices().getCameras().get(CAMERA2_DEVICE_ID));
+ assertEquals(topLevel.getDevices().getSmokeCoAlarms().size(), 4);
+ assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE1_DEVICE_ID));
+ assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE2_DEVICE_ID));
+ assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE3_DEVICE_ID));
+ assertNotNull(topLevel.getDevices().getSmokeCoAlarms().get(SMOKE4_DEVICE_ID));
+ }
+
+ @Test
+ public void verifyCompleteStreamingInput() throws IOException {
+ WWNTopLevelStreamingData topLevelStreamingData = fromJson("top-level-streaming-data.json",
+ WWNTopLevelStreamingData.class);
+
+ assertEquals("/", topLevelStreamingData.getPath());
+
+ WWNTopLevelData data = topLevelStreamingData.getData();
+ assertEquals(data.getDevices().getThermostats().size(), 1);
+ assertNotNull(data.getDevices().getThermostats().get(THERMOSTAT1_DEVICE_ID));
+ assertEquals(data.getDevices().getCameras().size(), 2);
+ assertNotNull(data.getDevices().getCameras().get(CAMERA1_DEVICE_ID));
+ assertNotNull(data.getDevices().getCameras().get(CAMERA2_DEVICE_ID));
+ assertEquals(data.getDevices().getSmokeCoAlarms().size(), 4);
+ assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE1_DEVICE_ID));
+ assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE2_DEVICE_ID));
+ assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE3_DEVICE_ID));
+ assertNotNull(data.getDevices().getSmokeCoAlarms().get(SMOKE4_DEVICE_ID));
+ }
+
+ @Test
+ public void verifyThermostat() throws IOException {
+ WWNThermostat thermostat = fromJson("thermostat-data.json", WWNThermostat.class);
+ logger.debug("Thermostat: {}", thermostat);
+
+ assertTrue(thermostat.isOnline());
+ assertTrue(thermostat.isCanHeat());
+ assertTrue(thermostat.isHasLeaf());
+ assertFalse(thermostat.isCanCool());
+ assertFalse(thermostat.isFanTimerActive());
+ assertFalse(thermostat.isLocked());
+ assertFalse(thermostat.isSunlightCorrectionActive());
+ assertTrue(thermostat.isSunlightCorrectionEnabled());
+ assertFalse(thermostat.isUsingEmergencyHeat());
+ assertEquals(THERMOSTAT1_DEVICE_ID, thermostat.getDeviceId());
+ assertEquals(Integer.valueOf(15), thermostat.getFanTimerDuration());
+ assertEqualDateTime("2017-02-02T21:00:06.000Z", thermostat.getLastConnection());
+ assertEqualDateTime("1970-01-01T00:00:00.000Z", thermostat.getFanTimerTimeout());
+ assertEquals(Double.valueOf(24.0), thermostat.getEcoTemperatureHigh());
+ assertEquals(Double.valueOf(12.5), thermostat.getEcoTemperatureLow());
+ assertEquals(Double.valueOf(22.0), thermostat.getLockedTempMax());
+ assertEquals(Double.valueOf(20.0), thermostat.getLockedTempMin());
+ assertEquals(WWNThermostat.Mode.HEAT, thermostat.getMode());
+ assertEquals("Living Room (Living Room)", thermostat.getName());
+ assertEquals("Living Room Thermostat (Living Room)", thermostat.getNameLong());
+ assertEquals(null, thermostat.getPreviousHvacMode());
+ assertEquals("5.6-7", thermostat.getSoftwareVersion());
+ assertEquals(WWNThermostat.State.OFF, thermostat.getHvacState());
+ assertEquals(STRUCTURE1_STRUCTURE_ID, thermostat.getStructureId());
+ assertEquals(Double.valueOf(15.5), thermostat.getTargetTemperature());
+ assertEquals(Double.valueOf(24.0), thermostat.getTargetTemperatureHigh());
+ assertEquals(Double.valueOf(20.0), thermostat.getTargetTemperatureLow());
+ assertEquals(SIUnits.CELSIUS, thermostat.getTemperatureUnit());
+ assertEquals(Integer.valueOf(0), thermostat.getTimeToTarget());
+ assertEquals(THERMOSTAT1_WHERE_ID, thermostat.getWhereId());
+ assertEquals("Living Room", thermostat.getWhereName());
+ }
+
+ @Test
+ public void thermostatTimeToTargetSupportedValueParsing() {
+ assertEquals((Integer) 0, WWNThermostat.parseTimeToTarget("~0"));
+ assertEquals((Integer) 5, WWNThermostat.parseTimeToTarget("<5"));
+ assertEquals((Integer) 10, WWNThermostat.parseTimeToTarget("<10"));
+ assertEquals((Integer) 15, WWNThermostat.parseTimeToTarget("~15"));
+ assertEquals((Integer) 90, WWNThermostat.parseTimeToTarget("~90"));
+ assertEquals((Integer) 120, WWNThermostat.parseTimeToTarget(">120"));
+ }
+
+ @Test
+ public void thermostatTimeToTargetUnsupportedValueParsing() {
+ assertThrows(NumberFormatException.class, () -> WWNThermostat.parseTimeToTarget("#5"));
+ }
+
+ @Test
+ public void verifyCamera() throws IOException {
+ WWNCamera camera = fromJson("camera-data.json", WWNCamera.class);
+ logger.debug("Camera: {}", camera);
+
+ assertTrue(camera.isOnline());
+ assertEquals("Upstairs", camera.getName());
+ assertEquals("Upstairs Camera", camera.getNameLong());
+ assertEquals(STRUCTURE1_STRUCTURE_ID, camera.getStructureId());
+ assertEquals(CAMERA1_WHERE_ID, camera.getWhereId());
+ assertTrue(camera.isAudioInputEnabled());
+ assertFalse(camera.isPublicShareEnabled());
+ assertFalse(camera.isStreaming());
+ assertFalse(camera.isVideoHistoryEnabled());
+ assertEquals("https://camera_app_url", camera.getAppUrl());
+ assertEquals(CAMERA1_DEVICE_ID, camera.getDeviceId());
+ assertNull(camera.getLastConnection());
+ assertEqualDateTime("2017-01-22T08:19:20.000Z", camera.getLastIsOnlineChange());
+ assertNull(camera.getPublicShareUrl());
+ assertEquals("https://camera_snapshot_url", camera.getSnapshotUrl());
+ assertEquals("205-600052", camera.getSoftwareVersion());
+ assertEquals("https://camera_web_url", camera.getWebUrl());
+ assertEquals("https://last_event_animated_image_url", camera.getLastEvent().getAnimatedImageUrl());
+ assertEquals(2, camera.getLastEvent().getActivityZones().size());
+ assertEquals("id1", camera.getLastEvent().getActivityZones().get(0));
+ assertEquals("https://last_event_app_url", camera.getLastEvent().getAppUrl());
+ assertEqualDateTime("2017-01-22T07:40:38.680Z", camera.getLastEvent().getEndTime());
+ assertEquals("https://last_event_image_url", camera.getLastEvent().getImageUrl());
+ assertEqualDateTime("2017-01-22T07:40:19.020Z", camera.getLastEvent().getStartTime());
+ assertEqualDateTime("2017-02-05T07:40:19.020Z", camera.getLastEvent().getUrlsExpireTime());
+ assertEquals("https://last_event_web_url", camera.getLastEvent().getWebUrl());
+ assertTrue(camera.getLastEvent().isHasMotion());
+ assertFalse(camera.getLastEvent().isHasPerson());
+ assertFalse(camera.getLastEvent().isHasSound());
+ }
+
+ @Test
+ public void verifySmokeDetector() throws IOException {
+ WWNSmokeDetector smokeDetector = fromJson("smoke-detector-data.json", WWNSmokeDetector.class);
+ logger.debug("SmokeDetector: {}", smokeDetector);
+
+ assertTrue(smokeDetector.isOnline());
+ assertEquals(SMOKE1_WHERE_ID, smokeDetector.getWhereId());
+ assertEquals(SMOKE1_DEVICE_ID, smokeDetector.getDeviceId());
+ assertEquals("Downstairs", smokeDetector.getName());
+ assertEquals("Downstairs Nest Protect", smokeDetector.getNameLong());
+ assertEqualDateTime("2017-02-02T20:53:05.338Z", smokeDetector.getLastConnection());
+ assertEquals(WWNSmokeDetector.BatteryHealth.OK, smokeDetector.getBatteryHealth());
+ assertEquals(WWNSmokeDetector.AlarmState.OK, smokeDetector.getCoAlarmState());
+ assertEquals(WWNSmokeDetector.AlarmState.OK, smokeDetector.getSmokeAlarmState());
+ assertEquals("3.1rc9", smokeDetector.getSoftwareVersion());
+ assertEquals(STRUCTURE1_STRUCTURE_ID, smokeDetector.getStructureId());
+ assertEquals(WWNSmokeDetector.UiColorState.GREEN, smokeDetector.getUiColorState());
+ }
+
+ @Test
+ public void verifyAccessToken() throws IOException {
+ WWNAccessTokenData accessToken = fromJson("access-token-data.json", WWNAccessTokenData.class);
+ logger.debug("AccessTokenData: {}", accessToken);
+
+ assertEquals("access_token", accessToken.getAccessToken());
+ assertEquals(Long.valueOf(315360000L), accessToken.getExpiresIn());
+ }
+
+ @Test
+ public void verifyStructure() throws IOException {
+ WWNStructure structure = fromJson("structure-data.json", WWNStructure.class);
+ logger.debug("Structure: {}", structure);
+
+ assertEquals("Home", structure.getName());
+ assertEquals("US", structure.getCountryCode());
+ assertEquals("98056", structure.getPostalCode());
+ assertEquals(WWNStructure.HomeAwayState.HOME, structure.getAway());
+ assertEqualDateTime("2017-02-02T03:10:08.000Z", structure.getEtaBegin());
+ assertNull(structure.getEta());
+ assertNull(structure.getPeakPeriodEndTime());
+ assertNull(structure.getPeakPeriodStartTime());
+ assertEquals(STRUCTURE1_STRUCTURE_ID, structure.getStructureId());
+ assertEquals("America/Los_Angeles", structure.getTimeZone());
+ assertFalse(structure.isRhrEnrollment());
+ }
+
+ @Test
+ public void verifyError() throws IOException {
+ WWNErrorData error = fromJson("error-data.json", WWNErrorData.class);
+ logger.debug("ErrorData: {}", error);
+
+ assertEquals("blocked", error.getError());
+ assertEquals("https://developer.nest.com/documentation/cloud/error-messages#blocked", error.getType());
+ assertEquals("blocked", error.getMessage());
+ assertEquals("bb514046-edc9-4bca-8239-f7a3cfb0925a", error.getInstance());
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.nest.internal.wwn.config.WWNAccountConfiguration;
+import org.openhab.binding.nest.internal.wwn.test.WWNTestAccountHandler;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+
+/**
+ * Tests cases for {@link WWNAccountHandler}.
+ *
+ * @author David Bennett - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+public class WWNAccountHandlerTest {
+
+ private ThingHandler handler;
+
+ private @Mock Bridge bridge;
+ private @Mock ThingHandlerCallback callback;
+ private @Mock ClientBuilder clientBuilder;
+ private @Mock Configuration configuration;
+ private @Mock SseEventSourceFactory eventSourceFactory;
+ private @Mock WWNRedirectUrlSupplier redirectUrlSupplier;
+
+ @BeforeEach
+ public void beforeEach() {
+ handler = new WWNTestAccountHandler(bridge, clientBuilder, eventSourceFactory, "http://localhost");
+ handler.setCallback(callback);
+ }
+
+ @SuppressWarnings("null")
+ @Test
+ public void initializeShouldCallTheCallback() {
+ when(bridge.getConfiguration()).thenReturn(configuration);
+ WWNAccountConfiguration bridgeConfig = new WWNAccountConfiguration();
+ when(configuration.as(eq(WWNAccountConfiguration.class))).thenReturn(bridgeConfig);
+ bridgeConfig.accessToken = "my token";
+
+ // we expect the handler#initialize method to call the callback during execution and
+ // pass it the thing and a ThingStatusInfo object containing the ThingStatus of the thing.
+ handler.initialize();
+
+ // the argument captor will capture the argument of type ThingStatusInfo given to the
+ // callback#statusUpdated method.
+ ArgumentCaptor<ThingStatusInfo> statusInfoCaptor = ArgumentCaptor.forClass(ThingStatusInfo.class);
+
+ // verify the interaction with the callback and capture the ThingStatusInfo argument:
+ verify(callback).statusUpdated(eq(bridge), statusInfoCaptor.capture());
+ // assert that the ThingStatusInfo given to the callback was build with the UNKNOWN status:
+ ThingStatusInfo thingStatusInfo = statusInfoCaptor.getValue();
+ assertThat(thingStatusInfo.getStatus(), is(equalTo(ThingStatus.UNKNOWN)));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.binding.nest.internal.wwn.dto.WWNDataUtil.*;
+import static org.openhab.core.library.types.OnOffType.*;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.wwn.config.WWNDeviceConfiguration;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+
+/**
+ * Tests for {@link WWNCameraHandler}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class WWNCameraHandlerTest extends WWNThingHandlerOSGiTest {
+
+ private static final ThingUID CAMERA_UID = new ThingUID(THING_TYPE_CAMERA, "camera1");
+ private static final int CHANNEL_COUNT = 20;
+
+ public WWNCameraHandlerTest() {
+ super(WWNCameraHandler.class);
+ }
+
+ @Override
+ protected Thing buildThing(Bridge bridge) {
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(WWNDeviceConfiguration.DEVICE_ID, CAMERA1_DEVICE_ID);
+
+ return ThingBuilder.create(THING_TYPE_CAMERA, CAMERA_UID).withLabel("Test Camera").withBridge(bridge.getUID())
+ .withChannels(buildChannels(THING_TYPE_CAMERA, CAMERA_UID))
+ .withConfiguration(new Configuration(properties)).build();
+ }
+
+ @Test
+ public void completeCameraUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ // Camera channel group
+ assertThatItemHasState(CHANNEL_CAMERA_APP_URL, new StringType("https://camera_app_url"));
+ assertThatItemHasState(CHANNEL_CAMERA_AUDIO_INPUT_ENABLED, ON);
+ assertThatItemHasState(CHANNEL_CAMERA_LAST_ONLINE_CHANGE, parseDateTimeType("2017-01-22T08:19:20.000Z"));
+ assertThatItemHasState(CHANNEL_CAMERA_PUBLIC_SHARE_ENABLED, OFF);
+ assertThatItemHasState(CHANNEL_CAMERA_PUBLIC_SHARE_URL, new StringType("https://camera_public_share_url"));
+ assertThatItemHasState(CHANNEL_CAMERA_SNAPSHOT_URL, new StringType("https://camera_snapshot_url"));
+ assertThatItemHasState(CHANNEL_CAMERA_STREAMING, OFF);
+ assertThatItemHasState(CHANNEL_CAMERA_VIDEO_HISTORY_ENABLED, OFF);
+ assertThatItemHasState(CHANNEL_CAMERA_WEB_URL, new StringType("https://camera_web_url"));
+
+ // Last event channel group
+ assertThatItemHasState(CHANNEL_LAST_EVENT_ACTIVITY_ZONES, new StringType("id1,id2"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_ANIMATED_IMAGE_URL,
+ new StringType("https://last_event_animated_image_url"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_APP_URL, new StringType("https://last_event_app_url"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_END_TIME, parseDateTimeType("2017-01-22T07:40:38.680Z"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_HAS_MOTION, ON);
+ assertThatItemHasState(CHANNEL_LAST_EVENT_HAS_PERSON, OFF);
+ assertThatItemHasState(CHANNEL_LAST_EVENT_HAS_SOUND, OFF);
+ assertThatItemHasState(CHANNEL_LAST_EVENT_IMAGE_URL, new StringType("https://last_event_image_url"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_START_TIME, parseDateTimeType("2017-01-22T07:40:19.020Z"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_URLS_EXPIRE_TIME, parseDateTimeType("2017-02-05T07:40:19.020Z"));
+ assertThatItemHasState(CHANNEL_LAST_EVENT_WEB_URL, new StringType("https://last_event_web_url"));
+
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void incompleteCameraUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.UNKNOWN)));
+ assertThatAllItemStatesAreNull();
+ }
+
+ @Test
+ public void cameraGone() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
+ assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
+ }
+
+ @Test
+ public void channelRefresh() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ updateAllItemStatesToNull();
+ assertThatAllItemStatesAreNull();
+
+ refreshAllChannels();
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void handleStreamingCommands() throws IOException {
+ handleCommand(CHANNEL_CAMERA_STREAMING, ON);
+ assertNestApiPropertyState(CAMERA1_DEVICE_ID, "is_streaming", "true");
+
+ handleCommand(CHANNEL_CAMERA_STREAMING, OFF);
+ assertNestApiPropertyState(CAMERA1_DEVICE_ID, "is_streaming", "false");
+
+ handleCommand(CHANNEL_CAMERA_STREAMING, ON);
+ assertNestApiPropertyState(CAMERA1_DEVICE_ID, "is_streaming", "true");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.binding.nest.internal.wwn.dto.WWNDataUtil.*;
+import static org.openhab.core.library.types.OnOffType.OFF;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.wwn.config.WWNDeviceConfiguration;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+
+/**
+ * Tests for {@link WWNSmokeDetectorHandler}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class WWNSmokeDetectorHandlerTest extends WWNThingHandlerOSGiTest {
+
+ private static final ThingUID SMOKE_DETECTOR_UID = new ThingUID(THING_TYPE_SMOKE_DETECTOR, "smoke1");
+ private static final int CHANNEL_COUNT = 7;
+
+ public WWNSmokeDetectorHandlerTest() {
+ super(WWNSmokeDetectorHandler.class);
+ }
+
+ @Override
+ protected Thing buildThing(Bridge bridge) {
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(WWNDeviceConfiguration.DEVICE_ID, SMOKE1_DEVICE_ID);
+
+ return ThingBuilder.create(THING_TYPE_SMOKE_DETECTOR, SMOKE_DETECTOR_UID).withLabel("Test Smoke Detector")
+ .withBridge(bridge.getUID()).withChannels(buildChannels(THING_TYPE_SMOKE_DETECTOR, SMOKE_DETECTOR_UID))
+ .withConfiguration(new Configuration(properties)).build();
+ }
+
+ @Test
+ public void completeSmokeDetectorUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ assertThatItemHasState(CHANNEL_CO_ALARM_STATE, new StringType("OK"));
+ assertThatItemHasState(CHANNEL_LAST_CONNECTION, parseDateTimeType("2017-02-02T20:53:05.338Z"));
+ assertThatItemHasState(CHANNEL_LAST_MANUAL_TEST_TIME, parseDateTimeType("2016-10-31T23:59:59.000Z"));
+ assertThatItemHasState(CHANNEL_LOW_BATTERY, OFF);
+ assertThatItemHasState(CHANNEL_MANUAL_TEST_ACTIVE, OFF);
+ assertThatItemHasState(CHANNEL_SMOKE_ALARM_STATE, new StringType("OK"));
+ assertThatItemHasState(CHANNEL_UI_COLOR_STATE, new StringType("GREEN"));
+
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void incompleteSmokeDetectorUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.UNKNOWN)));
+ assertThatAllItemStatesAreNull();
+ }
+
+ @Test
+ public void smokeDetectorGone() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
+ assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
+ }
+
+ @Test
+ public void channelRefresh() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ updateAllItemStatesToNull();
+ assertThatAllItemStatesAreNull();
+
+ refreshAllChannels();
+ assertThatAllItemStatesAreNotNull();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.binding.nest.internal.wwn.dto.WWNDataUtil.*;
+import static org.openhab.core.library.types.OnOffType.OFF;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.wwn.config.WWNStructureConfiguration;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+
+/**
+ * Tests for {@link WWNStructureHandler}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class WWNStructureHandlerTest extends WWNThingHandlerOSGiTest {
+
+ private static final ThingUID STRUCTURE_UID = new ThingUID(THING_TYPE_STRUCTURE, "structure1");
+ private static final int CHANNEL_COUNT = 11;
+
+ public WWNStructureHandlerTest() {
+ super(WWNStructureHandler.class);
+ }
+
+ @Override
+ protected Thing buildThing(Bridge bridge) {
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(WWNStructureConfiguration.STRUCTURE_ID, STRUCTURE1_STRUCTURE_ID);
+
+ return ThingBuilder.create(THING_TYPE_STRUCTURE, STRUCTURE_UID).withLabel("Test Structure")
+ .withBridge(bridge.getUID()).withChannels(buildChannels(THING_TYPE_STRUCTURE, STRUCTURE_UID))
+ .withConfiguration(new Configuration(properties)).build();
+ }
+
+ @Test
+ public void completeStructureUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ assertThatItemHasState(CHANNEL_AWAY, new StringType("HOME"));
+ assertThatItemHasState(CHANNEL_CO_ALARM_STATE, new StringType("OK"));
+ assertThatItemHasState(CHANNEL_COUNTRY_CODE, new StringType("US"));
+ assertThatItemHasState(CHANNEL_ETA_BEGIN, parseDateTimeType("2017-02-02T03:10:08.000Z"));
+ assertThatItemHasState(CHANNEL_PEAK_PERIOD_END_TIME, parseDateTimeType("2017-07-01T01:03:08.400Z"));
+ assertThatItemHasState(CHANNEL_PEAK_PERIOD_START_TIME, parseDateTimeType("2017-06-01T13:31:10.870Z"));
+ assertThatItemHasState(CHANNEL_POSTAL_CODE, new StringType("98056"));
+ assertThatItemHasState(CHANNEL_RUSH_HOUR_REWARDS_ENROLLMENT, OFF);
+ assertThatItemHasState(CHANNEL_SECURITY_STATE, new StringType("OK"));
+ assertThatItemHasState(CHANNEL_SMOKE_ALARM_STATE, new StringType("OK"));
+ assertThatItemHasState(CHANNEL_TIME_ZONE, new StringType("America/Los_Angeles"));
+
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void incompleteStructureUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNull();
+ }
+
+ @Test
+ public void structureGone() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
+ assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
+ }
+
+ @Test
+ public void channelRefresh() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ updateAllItemStatesToNull();
+ assertThatAllItemStatesAreNull();
+
+ refreshAllChannels();
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void handleAwayCommands() throws IOException {
+ handleCommand(CHANNEL_AWAY, new StringType("AWAY"));
+ assertNestApiPropertyState(STRUCTURE1_STRUCTURE_ID, "away", "away");
+
+ handleCommand(CHANNEL_AWAY, new StringType("HOME"));
+ assertNestApiPropertyState(STRUCTURE1_STRUCTURE_ID, "away", "home");
+
+ handleCommand(CHANNEL_AWAY, new StringType("AWAY"));
+ assertNestApiPropertyState(STRUCTURE1_STRUCTURE_ID, "away", "away");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.binding.nest.internal.wwn.dto.WWNDataUtil.*;
+import static org.openhab.core.library.types.OnOffType.*;
+import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
+import static org.openhab.core.library.unit.SIUnits.CELSIUS;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.nest.internal.wwn.config.WWNDeviceConfiguration;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+
+/**
+ * Tests for {@link WWNThermostatHandler}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class WWNThermostatHandlerTest extends WWNThingHandlerOSGiTest {
+
+ private static final ThingUID THERMOSTAT_UID = new ThingUID(THING_TYPE_THERMOSTAT, "thermostat1");
+ private static final int CHANNEL_COUNT = 25;
+
+ public WWNThermostatHandlerTest() {
+ super(WWNThermostatHandler.class);
+ }
+
+ @Override
+ protected Thing buildThing(Bridge bridge) {
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(WWNDeviceConfiguration.DEVICE_ID, THERMOSTAT1_DEVICE_ID);
+
+ return ThingBuilder.create(THING_TYPE_THERMOSTAT, THERMOSTAT_UID).withLabel("Test Thermostat")
+ .withBridge(bridge.getUID()).withChannels(buildChannels(THING_TYPE_THERMOSTAT, THERMOSTAT_UID))
+ .withConfiguration(new Configuration(properties)).build();
+ }
+
+ @Test
+ public void completeThermostatCelsiusUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, CELSIUS));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ assertThatItemHasState(CHANNEL_CAN_COOL, OFF);
+ assertThatItemHasState(CHANNEL_CAN_HEAT, ON);
+ assertThatItemHasState(CHANNEL_ECO_MAX_SET_POINT, new QuantityType<>(24, CELSIUS));
+ assertThatItemHasState(CHANNEL_ECO_MIN_SET_POINT, new QuantityType<>(12.5, CELSIUS));
+ assertThatItemHasState(CHANNEL_FAN_TIMER_ACTIVE, OFF);
+ assertThatItemHasState(CHANNEL_FAN_TIMER_DURATION, new QuantityType<>(15, Units.MINUTE));
+ assertThatItemHasState(CHANNEL_FAN_TIMER_TIMEOUT, parseDateTimeType("1970-01-01T00:00:00.000Z"));
+ assertThatItemHasState(CHANNEL_HAS_FAN, ON);
+ assertThatItemHasState(CHANNEL_HAS_LEAF, ON);
+ assertThatItemHasState(CHANNEL_HUMIDITY, new QuantityType<>(25, Units.PERCENT));
+ assertThatItemHasState(CHANNEL_LAST_CONNECTION, parseDateTimeType("2017-02-02T21:00:06.000Z"));
+ assertThatItemHasState(CHANNEL_LOCKED, OFF);
+ assertThatItemHasState(CHANNEL_LOCKED_MAX_SET_POINT, new QuantityType<>(22, CELSIUS));
+ assertThatItemHasState(CHANNEL_LOCKED_MIN_SET_POINT, new QuantityType<>(20, CELSIUS));
+ assertThatItemHasState(CHANNEL_MAX_SET_POINT, new QuantityType<>(24, CELSIUS));
+ assertThatItemHasState(CHANNEL_MIN_SET_POINT, new QuantityType<>(20, CELSIUS));
+ assertThatItemHasState(CHANNEL_MODE, new StringType("HEAT"));
+ assertThatItemHasState(CHANNEL_PREVIOUS_MODE, new StringType("HEAT"));
+ assertThatItemHasState(CHANNEL_SET_POINT, new QuantityType<>(15.5, CELSIUS));
+ assertThatItemHasState(CHANNEL_STATE, new StringType("OFF"));
+ assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ACTIVE, OFF);
+ assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ENABLED, ON);
+ assertThatItemHasState(CHANNEL_TEMPERATURE, new QuantityType<>(19, CELSIUS));
+ assertThatItemHasState(CHANNEL_TIME_TO_TARGET, new QuantityType<>(0, Units.MINUTE));
+ assertThatItemHasState(CHANNEL_USING_EMERGENCY_HEAT, OFF);
+
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void completeThermostatFahrenheitUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, FAHRENHEIT));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ assertThatItemHasState(CHANNEL_CAN_COOL, OFF);
+ assertThatItemHasState(CHANNEL_CAN_HEAT, ON);
+ assertThatItemHasState(CHANNEL_ECO_MAX_SET_POINT, new QuantityType<>(76, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_ECO_MIN_SET_POINT, new QuantityType<>(55, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_FAN_TIMER_ACTIVE, OFF);
+ assertThatItemHasState(CHANNEL_FAN_TIMER_DURATION, new QuantityType<>(15, Units.MINUTE));
+ assertThatItemHasState(CHANNEL_FAN_TIMER_TIMEOUT, parseDateTimeType("1970-01-01T00:00:00.000Z"));
+ assertThatItemHasState(CHANNEL_HAS_FAN, ON);
+ assertThatItemHasState(CHANNEL_HAS_LEAF, ON);
+ assertThatItemHasState(CHANNEL_HUMIDITY, new QuantityType<>(25, Units.PERCENT));
+ assertThatItemHasState(CHANNEL_LAST_CONNECTION, parseDateTimeType("2017-02-02T21:00:06.000Z"));
+ assertThatItemHasState(CHANNEL_LOCKED, OFF);
+ assertThatItemHasState(CHANNEL_LOCKED_MAX_SET_POINT, new QuantityType<>(72, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_LOCKED_MIN_SET_POINT, new QuantityType<>(68, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_MAX_SET_POINT, new QuantityType<>(75, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_MIN_SET_POINT, new QuantityType<>(68, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_MODE, new StringType("HEAT"));
+ assertThatItemHasState(CHANNEL_PREVIOUS_MODE, new StringType("HEAT"));
+ assertThatItemHasState(CHANNEL_SET_POINT, new QuantityType<>(60, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_STATE, new StringType("OFF"));
+ assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ACTIVE, OFF);
+ assertThatItemHasState(CHANNEL_SUNLIGHT_CORRECTION_ENABLED, ON);
+ assertThatItemHasState(CHANNEL_TEMPERATURE, new QuantityType<>(66, FAHRENHEIT));
+ assertThatItemHasState(CHANNEL_TIME_TO_TARGET, new QuantityType<>(0, Units.MINUTE));
+ assertThatItemHasState(CHANNEL_USING_EMERGENCY_HEAT, OFF);
+
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void incompleteThermostatUpdate() throws IOException {
+ assertThat(thing.getChannels().size(), is(CHANNEL_COUNT));
+ assertThat(thing.getStatus(), is(ThingStatus.OFFLINE));
+
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ putStreamingEventData(fromFile(INCOMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.UNKNOWN)));
+ assertThatAllItemStatesAreNull();
+ }
+
+ @Test
+ public void thermostatGone() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ putStreamingEventData(fromFile(EMPTY_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.OFFLINE)));
+ assertThat(thing.getStatusInfo().getStatusDetail(), is(ThingStatusDetail.GONE));
+ }
+
+ @Test
+ public void channelRefresh() throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+ assertThatAllItemStatesAreNotNull();
+
+ updateAllItemStatesToNull();
+ assertThatAllItemStatesAreNull();
+
+ refreshAllChannels();
+ assertThatAllItemStatesAreNotNull();
+ }
+
+ @Test
+ public void handleFanTimerActiveCommands() throws IOException {
+ handleCommand(CHANNEL_FAN_TIMER_ACTIVE, ON);
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_active", "true");
+
+ handleCommand(CHANNEL_FAN_TIMER_ACTIVE, OFF);
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_active", "false");
+
+ handleCommand(CHANNEL_FAN_TIMER_ACTIVE, ON);
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_active", "true");
+ }
+
+ @Test
+ public void handleFanTimerDurationCommands() throws IOException {
+ int[] durations = { 15, 30, 45, 60, 120, 240, 480, 960, 15 };
+ for (int duration : durations) {
+ handleCommand(CHANNEL_FAN_TIMER_DURATION, new QuantityType<>(duration, Units.MINUTE));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "fan_timer_duration", String.valueOf(duration));
+ }
+ }
+
+ @Test
+ public void handleMaxSetPointCelsiusCommands() throws IOException {
+ celsiusCommandsTest(CHANNEL_MAX_SET_POINT, "target_temperature_high_c");
+ }
+
+ @Test
+ public void handleMaxSetPointFahrenheitCommands() throws IOException {
+ fahrenheitCommandsTest(CHANNEL_MAX_SET_POINT, "target_temperature_high_f");
+ }
+
+ @Test
+ public void handleMinSetPointCelsiusCommands() throws IOException {
+ celsiusCommandsTest(CHANNEL_MIN_SET_POINT, "target_temperature_low_c");
+ }
+
+ @Test
+ public void handleMinSetPointFahrenheitCommands() throws IOException {
+ fahrenheitCommandsTest(CHANNEL_MIN_SET_POINT, "target_temperature_low_f");
+ }
+
+ @Test
+ public void handleChannelModeCommands() throws IOException {
+ handleCommand(CHANNEL_MODE, new StringType("HEAT"));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "heat");
+
+ handleCommand(CHANNEL_MODE, new StringType("COOL"));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "cool");
+
+ handleCommand(CHANNEL_MODE, new StringType("HEAT_COOL"));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "heat-cool");
+
+ handleCommand(CHANNEL_MODE, new StringType("ECO"));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "eco");
+
+ handleCommand(CHANNEL_MODE, new StringType("OFF"));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "off");
+
+ handleCommand(CHANNEL_MODE, new StringType("HEAT"));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, "hvac_mode", "heat");
+ }
+
+ @Test
+ public void handleSetPointCelsiusCommands() throws IOException {
+ celsiusCommandsTest(CHANNEL_SET_POINT, "target_temperature_c");
+ }
+
+ @Test
+ public void handleSetPointFahrenheitCommands() throws IOException {
+ fahrenheitCommandsTest(CHANNEL_SET_POINT, "target_temperature_f");
+ }
+
+ private void celsiusCommandsTest(String channelId, String apiPropertyName) throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, CELSIUS));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ handleCommand(channelId, new QuantityType<>(20, CELSIUS));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "20.0");
+
+ handleCommand(channelId, new QuantityType<>(21.123, CELSIUS));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "21.0");
+
+ handleCommand(channelId, new QuantityType<>(22.541, CELSIUS));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "22.5");
+
+ handleCommand(channelId, new QuantityType<>(23.74, CELSIUS));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "23.5");
+
+ handleCommand(channelId, new QuantityType<>(23.75, CELSIUS));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "24.0");
+
+ handleCommand(channelId, new QuantityType<>(70, FAHRENHEIT));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "21.0");
+ }
+
+ private void fahrenheitCommandsTest(String channelId, String apiPropertyName) throws IOException {
+ waitForAssert(() -> assertThat(bridge.getStatus(), is(ThingStatus.ONLINE)));
+ putStreamingEventData(fromFile(COMPLETE_DATA_FILE_NAME, FAHRENHEIT));
+ waitForAssert(() -> assertThat(thing.getStatus(), is(ThingStatus.ONLINE)));
+
+ handleCommand(channelId, new QuantityType<>(70, FAHRENHEIT));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "70");
+
+ handleCommand(channelId, new QuantityType<>(71.123, FAHRENHEIT));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "71");
+
+ handleCommand(channelId, new QuantityType<>(71.541, FAHRENHEIT));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "72");
+
+ handleCommand(channelId, new QuantityType<>(72.74, FAHRENHEIT));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "73");
+
+ handleCommand(channelId, new QuantityType<>(73.75, FAHRENHEIT));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "74");
+
+ handleCommand(channelId, new QuantityType<>(21, CELSIUS));
+ assertNestApiPropertyState(THERMOSTAT1_DEVICE_ID, apiPropertyName, "70");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.handler;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNot.not;
+import static org.mockito.Mockito.*;
+import static org.openhab.binding.nest.internal.wwn.rest.WWNStreamingRestClient.PUT;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.function.Function;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.openhab.binding.nest.internal.wwn.config.WWNAccountConfiguration;
+import org.openhab.binding.nest.internal.wwn.test.WWNTestAccountHandler;
+import org.openhab.binding.nest.internal.wwn.test.WWNTestApiServlet;
+import org.openhab.binding.nest.internal.wwn.test.WWNTestHandlerFactory;
+import org.openhab.binding.nest.internal.wwn.test.WWNTestServer;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemFactory;
+import org.openhab.core.items.ItemNotFoundException;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.items.events.ItemEventFactory;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.test.TestPortUtil;
+import org.openhab.core.test.java.JavaOSGiTest;
+import org.openhab.core.test.storage.VolatileStorageService;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ManagedThingProvider;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingProvider;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.openhab.core.thing.binding.builder.BridgeBuilder;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.link.ItemChannelLink;
+import org.openhab.core.thing.link.ManagedItemChannelLinkProvider;
+import org.openhab.core.thing.type.ChannelDefinition;
+import org.openhab.core.thing.type.ChannelGroupDefinition;
+import org.openhab.core.thing.type.ChannelGroupType;
+import org.openhab.core.thing.type.ChannelGroupTypeRegistry;
+import org.openhab.core.thing.type.ChannelType;
+import org.openhab.core.thing.type.ChannelTypeRegistry;
+import org.openhab.core.thing.type.ThingType;
+import org.openhab.core.thing.type.ThingTypeRegistry;
+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.osgi.service.component.ComponentContext;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link WWNThingHandlerOSGiTest} is an abstract base class for Nest OSGi based tests.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public abstract class WWNThingHandlerOSGiTest extends JavaOSGiTest {
+
+ private static final String SERVER_HOST = "127.0.0.1";
+ private static final int SERVER_PORT = TestPortUtil.findFreePort();
+ private static final int SERVER_TIMEOUT = -1;
+ private static final String REDIRECT_URL = "http://" + SERVER_HOST + ":" + SERVER_PORT;
+
+ private final Logger logger = LoggerFactory.getLogger(WWNThingHandlerOSGiTest.class);
+
+ private static WWNTestServer server;
+ private static WWNTestApiServlet servlet = new WWNTestApiServlet();
+
+ private ChannelTypeRegistry channelTypeRegistry;
+ private ChannelGroupTypeRegistry channelGroupTypeRegistry;
+ private ItemFactory itemFactory;
+ private ItemRegistry itemRegistry;
+ private EventPublisher eventPublisher;
+ private ManagedThingProvider managedThingProvider;
+ private ThingTypeRegistry thingTypeRegistry;
+ private ManagedItemChannelLinkProvider managedItemChannelLinkProvider;
+ private VolatileStorageService volatileStorageService = new VolatileStorageService();
+
+ protected Bridge bridge;
+ protected WWNTestAccountHandler bridgeHandler;
+ protected Thing thing;
+ protected WWNBaseHandler<?> thingHandler;
+ private Class<? extends WWNBaseHandler<?>> thingClass;
+
+ private WWNTestHandlerFactory nestTestHandlerFactory;
+ private @NonNullByDefault({}) ClientBuilder clientBuilder;
+ private @NonNullByDefault({}) SseEventSourceFactory eventSourceFactory;
+
+ public WWNThingHandlerOSGiTest(Class<? extends WWNBaseHandler<?>> thingClass) {
+ this.thingClass = thingClass;
+ }
+
+ @BeforeAll
+ public static void setUpClass() throws Exception {
+ ServletHolder holder = new ServletHolder(servlet);
+ server = new WWNTestServer(SERVER_HOST, SERVER_PORT, SERVER_TIMEOUT, holder);
+ server.startServer();
+ }
+
+ @BeforeEach
+ public void setUp() throws ItemNotFoundException {
+ registerService(volatileStorageService);
+
+ managedThingProvider = getService(ThingProvider.class, ManagedThingProvider.class);
+ assertThat("Could not get ManagedThingProvider", managedThingProvider, is(notNullValue()));
+
+ thingTypeRegistry = getService(ThingTypeRegistry.class);
+ assertThat("Could not get ThingTypeRegistry", thingTypeRegistry, is(notNullValue()));
+
+ channelTypeRegistry = getService(ChannelTypeRegistry.class);
+ assertThat("Could not get ChannelTypeRegistry", channelTypeRegistry, is(notNullValue()));
+
+ channelGroupTypeRegistry = getService(ChannelGroupTypeRegistry.class);
+ assertThat("Could not get ChannelGroupTypeRegistry", channelGroupTypeRegistry, is(notNullValue()));
+
+ eventPublisher = getService(EventPublisher.class);
+ assertThat("Could not get EventPublisher", eventPublisher, is(notNullValue()));
+
+ itemFactory = getService(ItemFactory.class);
+ assertThat("Could not get ItemFactory", itemFactory, is(notNullValue()));
+
+ itemRegistry = getService(ItemRegistry.class);
+ assertThat("Could not get ItemRegistry", itemRegistry, is(notNullValue()));
+
+ managedItemChannelLinkProvider = getService(ManagedItemChannelLinkProvider.class);
+ assertThat("Could not get ManagedItemChannelLinkProvider", managedItemChannelLinkProvider, is(notNullValue()));
+
+ clientBuilder = getService(ClientBuilder.class);
+ assertThat("Could not get ClientBuilder", clientBuilder, is(notNullValue()));
+
+ eventSourceFactory = getService(SseEventSourceFactory.class);
+ assertThat("Could not get SseEventSourceFactory", eventSourceFactory, is(notNullValue()));
+
+ ComponentContext componentContext = mock(ComponentContext.class);
+ when(componentContext.getBundleContext()).thenReturn(bundleContext);
+
+ nestTestHandlerFactory = new WWNTestHandlerFactory(clientBuilder, eventSourceFactory);
+ nestTestHandlerFactory.activate(componentContext,
+ Map.of(WWNTestHandlerFactory.REDIRECT_URL_CONFIG_PROPERTY, REDIRECT_URL));
+ registerService(nestTestHandlerFactory);
+
+ nestTestHandlerFactory = getService(ThingHandlerFactory.class, WWNTestHandlerFactory.class);
+ assertThat("Could not get NestTestHandlerFactory", nestTestHandlerFactory, is(notNullValue()));
+
+ bridge = buildBridge();
+ thing = buildThing(bridge);
+
+ bridgeHandler = addThing(bridge, WWNTestAccountHandler.class);
+ thingHandler = addThing(thing, thingClass);
+
+ createAndLinkItems();
+ assertThatAllItemStatesAreNull();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ servlet.reset();
+ servlet.closeConnections();
+
+ if (thing != null) {
+ managedThingProvider.remove(thing.getUID());
+ }
+ if (bridge != null) {
+ managedThingProvider.remove(bridge.getUID());
+ }
+
+ unregisterService(volatileStorageService);
+ }
+
+ protected Bridge buildBridge() {
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(WWNAccountConfiguration.ACCESS_TOKEN,
+ "c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc");
+ properties.put(WWNAccountConfiguration.PINCODE, "64P2XRYT");
+ properties.put(WWNAccountConfiguration.PRODUCT_ID, "8fdf9885-ca07-4252-1aa3-f3d5ca9589e0");
+ properties.put(WWNAccountConfiguration.PRODUCT_SECRET, "QITLR3iyUlWaj9dbvCxsCKp4f");
+
+ return BridgeBuilder.create(WWNTestAccountHandler.THING_TYPE_TEST_BRIDGE, "test_account")
+ .withLabel("Test Account").withConfiguration(new Configuration(properties)).build();
+ }
+
+ protected abstract Thing buildThing(Bridge bridge);
+
+ protected List<Channel> buildChannels(ThingTypeUID thingTypeUID, ThingUID thingUID) {
+ waitForAssert(() -> assertThat(thingTypeRegistry.getThingType(thingTypeUID), notNullValue()));
+
+ ThingType thingType = thingTypeRegistry.getThingType(thingTypeUID);
+
+ List<Channel> channels = new ArrayList<>();
+ channels.addAll(buildChannels(thingUID, thingType.getChannelDefinitions(), (id) -> id));
+
+ for (ChannelGroupDefinition channelGroupDefinition : thingType.getChannelGroupDefinitions()) {
+ ChannelGroupType channelGroupType = channelGroupTypeRegistry
+ .getChannelGroupType(channelGroupDefinition.getTypeUID());
+ String groupId = channelGroupDefinition.getId();
+ if (channelGroupType != null) {
+ channels.addAll(
+ buildChannels(thingUID, channelGroupType.getChannelDefinitions(), (id) -> groupId + "#" + id));
+ }
+ }
+
+ channels.sort((Channel c1, Channel c2) -> c1.getUID().getId().compareTo(c2.getUID().getId()));
+ return channels;
+ }
+
+ protected List<Channel> buildChannels(ThingUID thingUID, List<ChannelDefinition> channelDefinitions,
+ Function<String, String> channelIdFunction) {
+ List<Channel> result = new ArrayList<>();
+ for (ChannelDefinition channelDefinition : channelDefinitions) {
+ ChannelType channelType = channelTypeRegistry.getChannelType(channelDefinition.getChannelTypeUID());
+ if (channelType != null) {
+ result.add(ChannelBuilder
+ .create(new ChannelUID(thingUID, channelIdFunction.apply(channelDefinition.getId())),
+ channelType.getItemType())
+ .build());
+ }
+ }
+ return result;
+ }
+
+ @SuppressWarnings("unchecked")
+ protected <T> T addThing(Thing thing, Class<T> thingHandlerClass) {
+ assertThat(thing.getHandler(), is(nullValue()));
+ managedThingProvider.add(thing);
+ waitForAssert(() -> assertThat(thing.getHandler(), notNullValue()));
+ assertThat(thing.getConfiguration(), is(notNullValue()));
+ assertThat(thing.getHandler(), is(instanceOf(thingHandlerClass)));
+ return (T) thing.getHandler();
+ }
+
+ protected String getThingId() {
+ return thing.getUID().getId();
+ }
+
+ protected ThingUID getThingUID() {
+ return thing.getUID();
+ }
+
+ protected void putStreamingEventData(String json) throws IOException {
+ String singleLineJson = json.replaceAll("\n\r\\s+", "").replaceAll("\n\\s+", "").replaceAll("\n\r", "")
+ .replaceAll("\n", "");
+ servlet.queueEvent(PUT, singleLineJson);
+ }
+
+ protected void createAndLinkItems() {
+ thing.getChannels().forEach(c -> {
+ String itemName = getItemName(c.getUID().getId());
+ Item item = itemFactory.createItem(c.getAcceptedItemType(), itemName);
+ if (item != null) {
+ itemRegistry.add(item);
+ }
+ managedItemChannelLinkProvider.add(new ItemChannelLink(itemName, c.getUID()));
+ });
+ }
+
+ protected void assertThatItemHasState(String channelId, State state) {
+ waitForAssert(() -> assertThat("Wrong state for item of channel '" + channelId + "' ", getItemState(channelId),
+ is(state)));
+ }
+
+ protected void assertThatItemHasNotState(String channelId, State state) {
+ waitForAssert(() -> assertThat("Wrong state for item of channel '" + channelId + "' ", getItemState(channelId),
+ is(not(state))));
+ }
+
+ protected void assertThatAllItemStatesAreNull() {
+ thing.getChannels().forEach(c -> assertThatItemHasState(c.getUID().getId(), UnDefType.NULL));
+ }
+
+ protected void assertThatAllItemStatesAreNotNull() {
+ thing.getChannels().forEach(c -> assertThatItemHasNotState(c.getUID().getId(), UnDefType.NULL));
+ }
+
+ protected ChannelUID getChannelUID(String channelId) {
+ return new ChannelUID(getThingUID(), channelId);
+ }
+
+ protected String getItemName(String channelId) {
+ return getThingId() + "_" + channelId.replaceAll("#", "_");
+ }
+
+ private State getItemState(String channelId) {
+ String itemName = getItemName(channelId);
+ try {
+ return itemRegistry.getItem(itemName).getState();
+ } catch (ItemNotFoundException e) {
+ throw new AssertionError("Item with name '" + itemName + "' not found");
+ }
+ }
+
+ protected void logItemStates() {
+ thing.getChannels().forEach(c -> {
+ String channelId = c.getUID().getId();
+ String itemName = getItemName(channelId);
+ logger.debug("{} = {}", itemName, getItemState(channelId));
+ });
+ }
+
+ protected void updateAllItemStatesToNull() {
+ thing.getChannels().forEach(c -> updateItemState(c.getUID().getId(), UnDefType.NULL));
+ }
+
+ protected void refreshAllChannels() {
+ thing.getChannels().forEach(c -> thingHandler.handleCommand(c.getUID(), RefreshType.REFRESH));
+ }
+
+ protected void handleCommand(String channelId, Command command) {
+ thingHandler.handleCommand(getChannelUID(channelId), command);
+ }
+
+ protected void updateItemState(String channelId, State state) {
+ String itemName = getItemName(channelId);
+ eventPublisher.post(ItemEventFactory.createStateEvent(itemName, state));
+ }
+
+ protected void assertNestApiPropertyState(String nestId, String propertyName, String state) {
+ waitForAssert(() -> assertThat(servlet.getNestIdPropertyState(nestId, propertyName), is(state)));
+ }
+
+ public static DateTimeType parseDateTimeType(String text) {
+ try {
+ return new DateTimeType(Instant.parse(text).atZone(TimeZone.getDefault().toZoneId()));
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("Invalid date time argument: " + text, e);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.test;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.BINDING_ID;
+
+import java.util.Properties;
+import java.util.Set;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.openhab.binding.nest.internal.wwn.exceptions.InvalidWWNAccessTokenException;
+import org.openhab.binding.nest.internal.wwn.handler.WWNAccountHandler;
+import org.openhab.binding.nest.internal.wwn.handler.WWNRedirectUrlSupplier;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ThingTypeUID;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+
+/**
+ * The {@link WWNTestAccountHandler} is a {@link WWNAccountHandler} modified for testing. Using the
+ * {@link NestTestRedirectUrlSupplier} it will always connect to same provided {@link #redirectUrl}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class WWNTestAccountHandler extends WWNAccountHandler {
+
+ class NestTestRedirectUrlSupplier extends WWNRedirectUrlSupplier {
+
+ NestTestRedirectUrlSupplier(Properties httpHeaders) {
+ super(httpHeaders);
+ this.cachedUrl = redirectUrl;
+ }
+
+ @Override
+ public void resetCache() {
+ // Skip resetting the URL so the test server keeps being used
+ }
+ }
+
+ public static final ThingTypeUID THING_TYPE_TEST_BRIDGE = new ThingTypeUID(BINDING_ID, "wwn_test_account");
+ public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_TEST_BRIDGE);
+
+ private String redirectUrl;
+
+ public WWNTestAccountHandler(Bridge bridge, ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory,
+ String redirectUrl) {
+ super(bridge, clientBuilder, eventSourceFactory);
+ this.redirectUrl = redirectUrl;
+ }
+
+ @Override
+ protected WWNRedirectUrlSupplier createRedirectUrlSupplier() throws InvalidWWNAccessTokenException {
+ return new NestTestRedirectUrlSupplier(getHttpHeaders());
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.test;
+
+import static org.openhab.binding.nest.internal.wwn.WWNBindingConstants.*;
+import static org.openhab.binding.nest.internal.wwn.rest.WWNStreamingRestClient.*;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link WWNTestApiServlet} mocks the Nest API during tests.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class WWNTestApiServlet extends HttpServlet {
+
+ private static final long serialVersionUID = -5414910055159062745L;
+
+ private static final String NEW_LINE = "\n";
+
+ private static final String UPDATE_PATHS[] = { NEST_CAMERA_UPDATE_PATH, NEST_SMOKE_ALARM_UPDATE_PATH,
+ NEST_STRUCTURE_UPDATE_PATH, NEST_THERMOSTAT_UPDATE_PATH };
+
+ private final Logger logger = LoggerFactory.getLogger(WWNTestApiServlet.class);
+
+ private class SseEvent {
+ private String name;
+ private String data;
+
+ public SseEvent(String name) {
+ this.name = name;
+ }
+
+ public SseEvent(String name, String data) {
+ this.name = name;
+ this.data = data;
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean hasData() {
+ return data != null && !data.isEmpty();
+ }
+ }
+
+ private final Map<String, Map<String, String>> nestIdPropertiesMap = new ConcurrentHashMap<>();
+
+ private final Map<Thread, Queue<SseEvent>> listenerQueues = new ConcurrentHashMap<>();
+
+ private final ThreadLocal<PrintWriter> threadLocalWriter = new ThreadLocal<>();
+
+ private final Gson gson = new GsonBuilder().create();
+
+ public void closeConnections() {
+ Set<Thread> threads = listenerQueues.keySet();
+ listenerQueues.clear();
+ threads.forEach(thread -> thread.interrupt());
+ }
+
+ public void reset() {
+ nestIdPropertiesMap.clear();
+ }
+
+ public void queueEvent(String eventName) {
+ SseEvent event = new SseEvent(eventName);
+ listenerQueues.forEach((thread, queue) -> queue.add(event));
+ }
+
+ public void queueEvent(String eventName, String data) {
+ SseEvent event = new SseEvent(eventName, data);
+ listenerQueues.forEach((thread, queue) -> queue.add(event));
+ }
+
+ @SuppressWarnings("resource")
+ private void writeEvent(SseEvent event) {
+ logger.debug("Writing {} event", event.getName());
+
+ PrintWriter writer = threadLocalWriter.get();
+
+ writer.write("event: ");
+ writer.write(event.getName());
+ writer.write(NEW_LINE);
+
+ if (event.hasData()) {
+ for (String dataLine : event.getData().split(NEW_LINE)) {
+ writer.write("data: ");
+ writer.write(dataLine);
+ writer.write(NEW_LINE);
+ }
+ }
+
+ writer.write(NEW_LINE);
+ writer.flush();
+ }
+
+ private void writeEvent(String eventName) {
+ writeEvent(new SseEvent(eventName));
+ }
+
+ @Override
+ public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ ArrayBlockingQueue<SseEvent> queue = new ArrayBlockingQueue<>(10);
+ listenerQueues.put(Thread.currentThread(), queue);
+
+ response.setContentType("text/event-stream");
+ response.setCharacterEncoding("UTF-8");
+ response.flushBuffer();
+
+ logger.debug("Opened event stream to {}:{}", request.getRemoteHost(), request.getRemotePort());
+
+ PrintWriter writer = response.getWriter();
+ threadLocalWriter.set(writer);
+ writeEvent(OPEN);
+
+ while (listenerQueues.containsKey(Thread.currentThread()) && !writer.checkError()) {
+ try {
+ SseEvent event = queue.poll(KEEP_ALIVE_MILLIS, TimeUnit.MILLISECONDS);
+ if (event != null) {
+ writeEvent(event);
+ } else {
+ writeEvent(KEEP_ALIVE);
+ }
+ } catch (InterruptedException e) {
+ logger.debug("Evaluating loop conditions after interrupt");
+ }
+ }
+
+ listenerQueues.remove(Thread.currentThread());
+ threadLocalWriter.remove();
+ writer.close();
+
+ logger.debug("Closed event stream to {}:{}", request.getRemoteHost(), request.getRemotePort());
+ }
+
+ @Override
+ protected void doPut(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ logger.debug("Received put request: {}", request);
+
+ String uri = request.getRequestURI();
+ String nestId = getNestIdFromURI(uri);
+
+ if (nestId == null) {
+ logger.error("Unsupported URI: {}", uri);
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ return;
+ }
+
+ InputStreamReader reader = new InputStreamReader(request.getInputStream());
+ Map<String, String> propertiesUpdate = gson.fromJson(reader, new TypeToken<Map<String, String>>() {
+ }.getType());
+
+ Map<String, String> properties = getOrCreateProperties(nestId);
+ properties.putAll(propertiesUpdate);
+
+ gson.toJson(propertiesUpdate, response.getWriter());
+
+ response.setStatus(HttpServletResponse.SC_OK);
+ }
+
+ private String getNestIdFromURI(String uri) {
+ for (String updatePath : UPDATE_PATHS) {
+ if (uri.startsWith(updatePath)) {
+ return uri.replaceAll(updatePath, "");
+ }
+ }
+ return null;
+ }
+
+ private Map<String, String> getOrCreateProperties(String nestId) {
+ Map<String, String> properties = nestIdPropertiesMap.get(nestId);
+ if (properties == null) {
+ properties = new HashMap<>();
+ nestIdPropertiesMap.put(nestId, properties);
+ }
+ return properties;
+ }
+
+ public String getNestIdPropertyState(String nestId, String propertyName) {
+ Map<String, String> properties = nestIdPropertiesMap.get(nestId);
+ return properties == null ? null : properties.get(propertyName);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.test;
+
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+
+import javax.ws.rs.client.ClientBuilder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nest.internal.wwn.discovery.WWNDiscoveryService;
+import org.openhab.binding.nest.internal.wwn.handler.WWNAccountHandler;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.component.ComponentContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.jaxrs.client.SseEventSourceFactory;
+
+/**
+ * The {@link WWNTestHandlerFactory} is responsible for creating test things and thing handlers.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WWNTestHandlerFactory extends BaseThingHandlerFactory implements ThingHandlerFactory {
+
+ public static final String REDIRECT_URL_CONFIG_PROPERTY = "redirect.url";
+
+ private final ClientBuilder clientBuilder;
+ private final SseEventSourceFactory eventSourceFactory;
+ private final Map<ThingUID, ServiceRegistration<?>> discoveryService = new HashMap<>();
+
+ private String redirectUrl = "http://localhost";
+
+ @Activate
+ public WWNTestHandlerFactory(@Reference ClientBuilder clientBuilder,
+ @Reference SseEventSourceFactory eventSourceFactory) {
+ this.clientBuilder = clientBuilder;
+ this.eventSourceFactory = eventSourceFactory;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return WWNTestAccountHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID);
+ }
+
+ @Activate
+ public void activate(ComponentContext componentContext, Map<String, Object> config) {
+ super.activate(componentContext);
+ modified(config);
+ }
+
+ @Modified
+ public void modified(Map<String, Object> config) {
+ String url = (String) config.get(REDIRECT_URL_CONFIG_PROPERTY);
+ if (url != null) {
+ this.redirectUrl = url;
+ }
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+ if (thingTypeUID.equals(WWNTestAccountHandler.THING_TYPE_TEST_BRIDGE)) {
+ WWNTestAccountHandler handler = new WWNTestAccountHandler((Bridge) thing, clientBuilder, eventSourceFactory,
+ redirectUrl);
+ WWNDiscoveryService service = new WWNDiscoveryService();
+ service.setThingHandler(handler);
+ // Register the discovery service.
+ discoveryService.put(handler.getThing().getUID(),
+ bundleContext.registerService(DiscoveryService.class.getName(), service, new Hashtable<>()));
+
+ return handler;
+ }
+ return null;
+ }
+
+ /**
+ * Removes the handler for the specific thing. This also handles disabling the discovery
+ * service when the bridge is removed.
+ */
+ @Override
+ protected void removeHandler(ThingHandler thingHandler) {
+ if (thingHandler instanceof WWNAccountHandler) {
+ ServiceRegistration<?> registration = discoveryService.get(thingHandler.getThing().getUID());
+ if (registration != null) {
+ // Unregister the discovery service.
+ WWNDiscoveryService service = (WWNDiscoveryService) bundleContext
+ .getService(registration.getReference());
+ service.deactivate();
+ registration.unregister();
+ discoveryService.remove(thingHandler.getThing().getUID());
+ }
+ }
+ super.removeHandler(thingHandler);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.nest.internal.wwn.test;
+
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Embedded jetty server used in the tests.
+ *
+ * Based on {@code TestServer} of the FS Internet Radio Binding.
+ *
+ * @author Velin Yordanov - Initial contribution
+ * @author Wouter Born - Increase test coverage
+ */
+public class WWNTestServer {
+ private final Logger logger = LoggerFactory.getLogger(WWNTestServer.class);
+
+ private Server server;
+ private String host;
+ private int port;
+ private int timeout;
+ private ServletHolder servletHolder;
+
+ public WWNTestServer(String host, int port, int timeout, ServletHolder servletHolder) {
+ this.host = host;
+ this.port = port;
+ this.timeout = timeout;
+ this.servletHolder = servletHolder;
+ }
+
+ public void startServer() {
+ Thread thread = new Thread(new Runnable() {
+ @Override
+ @SuppressWarnings("resource")
+ public void run() {
+ server = new Server();
+ ServletHandler handler = new ServletHandler();
+ handler.addServletWithMapping(servletHolder, "/*");
+ server.setHandler(handler);
+
+ // HTTP connector
+ ServerConnector http = new ServerConnector(server);
+ http.setHost(host);
+ http.setPort(port);
+ http.setIdleTimeout(timeout);
+
+ server.addConnector(http);
+
+ try {
+ server.start();
+ server.join();
+ } catch (InterruptedException ex) {
+ logger.error("Server got interrupted", ex);
+ return;
+ } catch (Exception e) {
+ logger.error("Error in starting the server", e);
+ return;
+ }
+ }
+ });
+
+ thread.start();
+ }
+
+ public void stopServer() {
+ try {
+ server.stop();
+ } catch (Exception e) {
+ logger.error("Error in stopping the server", e);
+ return;
+ }
+ }
+}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.test;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.*;
-import static org.openhab.binding.nest.internal.rest.NestStreamingRestClient.*;
-
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.PrintWriter;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Queue;
-import java.util.Set;
-import java.util.concurrent.ArrayBlockingQueue;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.TimeUnit;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.reflect.TypeToken;
-
-/**
- * The {@link NestTestApiServlet} mocks the Nest API during tests.
- *
- * @author Wouter Born - Increase test coverage
- */
-public class NestTestApiServlet extends HttpServlet {
-
- private static final long serialVersionUID = -5414910055159062745L;
-
- private static final String NEW_LINE = "\n";
-
- private static final String UPDATE_PATHS[] = { NEST_CAMERA_UPDATE_PATH, NEST_SMOKE_ALARM_UPDATE_PATH,
- NEST_STRUCTURE_UPDATE_PATH, NEST_THERMOSTAT_UPDATE_PATH };
-
- private final Logger logger = LoggerFactory.getLogger(NestTestApiServlet.class);
-
- private class SseEvent {
- private String name;
- private String data;
-
- public SseEvent(String name) {
- this.name = name;
- }
-
- public SseEvent(String name, String data) {
- this.name = name;
- this.data = data;
- }
-
- public String getData() {
- return data;
- }
-
- public String getName() {
- return name;
- }
-
- public boolean hasData() {
- return data != null && !data.isEmpty();
- }
- }
-
- private final Map<String, Map<String, String>> nestIdPropertiesMap = new ConcurrentHashMap<>();
-
- private final Map<Thread, Queue<SseEvent>> listenerQueues = new ConcurrentHashMap<>();
-
- private final ThreadLocal<PrintWriter> threadLocalWriter = new ThreadLocal<>();
-
- private final Gson gson = new GsonBuilder().create();
-
- public void closeConnections() {
- Set<Thread> threads = listenerQueues.keySet();
- listenerQueues.clear();
- threads.forEach(thread -> thread.interrupt());
- }
-
- public void reset() {
- nestIdPropertiesMap.clear();
- }
-
- public void queueEvent(String eventName) {
- SseEvent event = new SseEvent(eventName);
- listenerQueues.forEach((thread, queue) -> queue.add(event));
- }
-
- public void queueEvent(String eventName, String data) {
- SseEvent event = new SseEvent(eventName, data);
- listenerQueues.forEach((thread, queue) -> queue.add(event));
- }
-
- @SuppressWarnings("resource")
- private void writeEvent(SseEvent event) {
- logger.debug("Writing {} event", event.getName());
-
- PrintWriter writer = threadLocalWriter.get();
-
- writer.write("event: ");
- writer.write(event.getName());
- writer.write(NEW_LINE);
-
- if (event.hasData()) {
- for (String dataLine : event.getData().split(NEW_LINE)) {
- writer.write("data: ");
- writer.write(dataLine);
- writer.write(NEW_LINE);
- }
- }
-
- writer.write(NEW_LINE);
- writer.flush();
- }
-
- private void writeEvent(String eventName) {
- writeEvent(new SseEvent(eventName));
- }
-
- @Override
- public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
- ArrayBlockingQueue<SseEvent> queue = new ArrayBlockingQueue<>(10);
- listenerQueues.put(Thread.currentThread(), queue);
-
- response.setContentType("text/event-stream");
- response.setCharacterEncoding("UTF-8");
- response.flushBuffer();
-
- logger.debug("Opened event stream to {}:{}", request.getRemoteHost(), request.getRemotePort());
-
- PrintWriter writer = response.getWriter();
- threadLocalWriter.set(writer);
- writeEvent(OPEN);
-
- while (listenerQueues.containsKey(Thread.currentThread()) && !writer.checkError()) {
- try {
- SseEvent event = queue.poll(KEEP_ALIVE_MILLIS, TimeUnit.MILLISECONDS);
- if (event != null) {
- writeEvent(event);
- } else {
- writeEvent(KEEP_ALIVE);
- }
- } catch (InterruptedException e) {
- logger.debug("Evaluating loop conditions after interrupt");
- }
- }
-
- listenerQueues.remove(Thread.currentThread());
- threadLocalWriter.remove();
- writer.close();
-
- logger.debug("Closed event stream to {}:{}", request.getRemoteHost(), request.getRemotePort());
- }
-
- @Override
- protected void doPut(HttpServletRequest request, HttpServletResponse response)
- throws ServletException, IOException {
- logger.debug("Received put request: {}", request);
-
- String uri = request.getRequestURI();
- String nestId = getNestIdFromURI(uri);
-
- if (nestId == null) {
- logger.error("Unsupported URI: {}", uri);
- response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
- return;
- }
-
- InputStreamReader reader = new InputStreamReader(request.getInputStream());
- Map<String, String> propertiesUpdate = gson.fromJson(reader, new TypeToken<Map<String, String>>() {
- }.getType());
-
- Map<String, String> properties = getOrCreateProperties(nestId);
- properties.putAll(propertiesUpdate);
-
- gson.toJson(propertiesUpdate, response.getWriter());
-
- response.setStatus(HttpServletResponse.SC_OK);
- }
-
- private String getNestIdFromURI(String uri) {
- for (String updatePath : UPDATE_PATHS) {
- if (uri.startsWith(updatePath)) {
- return uri.replaceAll(updatePath, "");
- }
- }
- return null;
- }
-
- private Map<String, String> getOrCreateProperties(String nestId) {
- Map<String, String> properties = nestIdPropertiesMap.get(nestId);
- if (properties == null) {
- properties = new HashMap<>();
- nestIdPropertiesMap.put(nestId, properties);
- }
- return properties;
- }
-
- public String getNestIdPropertyState(String nestId, String propertyName) {
- Map<String, String> properties = nestIdPropertiesMap.get(nestId);
- return properties == null ? null : properties.get(propertyName);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.test;
-
-import static org.openhab.binding.nest.internal.NestBindingConstants.BINDING_ID;
-
-import java.util.Collections;
-import java.util.Properties;
-import java.util.Set;
-
-import javax.ws.rs.client.ClientBuilder;
-
-import org.openhab.binding.nest.internal.exceptions.InvalidAccessTokenException;
-import org.openhab.binding.nest.internal.handler.NestBridgeHandler;
-import org.openhab.binding.nest.internal.handler.NestRedirectUrlSupplier;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.ThingTypeUID;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-
-/**
- * The {@link NestTestBridgeHandler} is a {@link NestBridgeHandler} modified for testing. Using the
- * {@link NestTestRedirectUrlSupplier} it will always connect to same provided {@link #redirectUrl}.
- *
- * @author Wouter Born - Increase test coverage
- */
-public class NestTestBridgeHandler extends NestBridgeHandler {
-
- class NestTestRedirectUrlSupplier extends NestRedirectUrlSupplier {
-
- NestTestRedirectUrlSupplier(Properties httpHeaders) {
- super(httpHeaders);
- this.cachedUrl = redirectUrl;
- }
-
- @Override
- public void resetCache() {
- // Skip resetting the URL so the test server keeps being used
- }
- }
-
- public static final ThingTypeUID THING_TYPE_TEST_BRIDGE = new ThingTypeUID(BINDING_ID, "test_account");
- public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_TEST_BRIDGE);
-
- private String redirectUrl;
-
- public NestTestBridgeHandler(Bridge bridge, ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory,
- String redirectUrl) {
- super(bridge, clientBuilder, eventSourceFactory);
- this.redirectUrl = redirectUrl;
- }
-
- @Override
- protected NestRedirectUrlSupplier createRedirectUrlSupplier() throws InvalidAccessTokenException {
- return new NestTestRedirectUrlSupplier(getHttpHeaders());
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.test;
-
-import java.util.HashMap;
-import java.util.Hashtable;
-import java.util.Map;
-
-import javax.ws.rs.client.ClientBuilder;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.nest.internal.discovery.NestDiscoveryService;
-import org.openhab.binding.nest.internal.handler.NestBridgeHandler;
-import org.openhab.core.config.discovery.DiscoveryService;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.ThingTypeUID;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.BaseThingHandlerFactory;
-import org.openhab.core.thing.binding.ThingHandler;
-import org.openhab.core.thing.binding.ThingHandlerFactory;
-import org.osgi.framework.ServiceRegistration;
-import org.osgi.service.component.ComponentContext;
-import org.osgi.service.component.annotations.Activate;
-import org.osgi.service.component.annotations.Modified;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.jaxrs.client.SseEventSourceFactory;
-
-/**
- * The {@link NestTestHandlerFactory} is responsible for creating test things and thing handlers.
- *
- * @author Wouter Born - Increase test coverage
- */
-@NonNullByDefault
-public class NestTestHandlerFactory extends BaseThingHandlerFactory implements ThingHandlerFactory {
-
- public static final String REDIRECT_URL_CONFIG_PROPERTY = "redirect.url";
-
- private final ClientBuilder clientBuilder;
- private final SseEventSourceFactory eventSourceFactory;
- private final Map<ThingUID, ServiceRegistration<?>> discoveryService = new HashMap<>();
-
- private String redirectUrl = "http://localhost";
-
- @Activate
- public NestTestHandlerFactory(@Reference ClientBuilder clientBuilder,
- @Reference SseEventSourceFactory eventSourceFactory) {
- this.clientBuilder = clientBuilder;
- this.eventSourceFactory = eventSourceFactory;
- }
-
- @Override
- public boolean supportsThingType(ThingTypeUID thingTypeUID) {
- return NestTestBridgeHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID);
- }
-
- @Activate
- public void activate(ComponentContext componentContext, Map<String, Object> config) {
- super.activate(componentContext);
- modified(config);
- }
-
- @Modified
- public void modified(Map<String, Object> config) {
- String url = (String) config.get(REDIRECT_URL_CONFIG_PROPERTY);
- if (url != null) {
- this.redirectUrl = url;
- }
- }
-
- @Override
- protected @Nullable ThingHandler createHandler(Thing thing) {
- ThingTypeUID thingTypeUID = thing.getThingTypeUID();
- if (thingTypeUID.equals(NestTestBridgeHandler.THING_TYPE_TEST_BRIDGE)) {
- NestTestBridgeHandler handler = new NestTestBridgeHandler((Bridge) thing, clientBuilder, eventSourceFactory,
- redirectUrl);
- NestDiscoveryService service = new NestDiscoveryService(handler);
- // Register the discovery service.
- discoveryService.put(handler.getThing().getUID(),
- bundleContext.registerService(DiscoveryService.class.getName(), service, new Hashtable<>()));
-
- return handler;
- }
- return null;
- }
-
- /**
- * Removes the handler for the specific thing. This also handles disabling the discovery
- * service when the bridge is removed.
- */
- @Override
- protected void removeHandler(ThingHandler thingHandler) {
- if (thingHandler instanceof NestBridgeHandler) {
- ServiceRegistration<?> registration = discoveryService.get(thingHandler.getThing().getUID());
- if (registration != null) {
- // Unregister the discovery service.
- NestDiscoveryService service = (NestDiscoveryService) bundleContext
- .getService(registration.getReference());
- service.deactivate();
- registration.unregister();
- discoveryService.remove(thingHandler.getThing().getUID());
- }
- }
- super.removeHandler(thingHandler);
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.nest.test;
-
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.servlet.ServletHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Embedded jetty server used in the tests.
- *
- * Based on {@code TestServer} of the FS Internet Radio Binding.
- *
- * @author Velin Yordanov - initial contribution
- * @author Wouter Born - Increase test coverage
- */
-public class NestTestServer {
- private final Logger logger = LoggerFactory.getLogger(NestTestServer.class);
-
- private Server server;
- private String host;
- private int port;
- private int timeout;
- private ServletHolder servletHolder;
-
- public NestTestServer(String host, int port, int timeout, ServletHolder servletHolder) {
- this.host = host;
- this.port = port;
- this.timeout = timeout;
- this.servletHolder = servletHolder;
- }
-
- public void startServer() {
- Thread thread = new Thread(new Runnable() {
- @Override
- @SuppressWarnings("resource")
- public void run() {
- server = new Server();
- ServletHandler handler = new ServletHandler();
- handler.addServletWithMapping(servletHolder, "/*");
- server.setHandler(handler);
-
- // HTTP connector
- ServerConnector http = new ServerConnector(server);
- http.setHost(host);
- http.setPort(port);
- http.setIdleTimeout(timeout);
-
- server.addConnector(http);
-
- try {
- server.start();
- server.join();
- } catch (InterruptedException ex) {
- logger.error("Server got interrupted", ex);
- return;
- } catch (Exception e) {
- logger.error("Error in starting the server", e);
- return;
- }
- }
- });
-
- thread.start();
- }
-
- public void stopServer() {
- try {
- server.stop();
- } catch (Exception e) {
- logger.error("Error in stopping the server", e);
- return;
- }
- }
-}
+++ /dev/null
-{
- "access_token": "access_token",
- "expires_in": 315360000
-}
+++ /dev/null
-{
- "app_url": "https://camera_app_url",
- "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
- "is_audio_input_enabled": true,
- "is_online": true,
- "is_public_share_enabled": false,
- "is_streaming": false,
- "is_video_history_enabled": false,
- "last_event": {
- "activity_zone_ids": [
- "id1",
- "id2"
- ],
- "animated_image_url": "https://last_event_animated_image_url",
- "app_url": "https://last_event_app_url",
- "end_time": "2017-01-22T07:40:38.680Z",
- "has_motion": true,
- "has_person": false,
- "has_sound": false,
- "image_url": "https://last_event_image_url",
- "start_time": "2017-01-22T07:40:19.020Z",
- "urls_expire_time": "2017-02-05T07:40:19.020Z",
- "web_url": "https://last_event_web_url"
- },
- "last_is_online_change": "2017-01-22T08:19:20.000Z",
- "name": "Upstairs",
- "name_long": "Upstairs Camera",
- "snapshot_url": "https://camera_snapshot_url",
- "software_version": "205-600052",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "web_url": "https://camera_web_url",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
-}
+++ /dev/null
-{
- "error": "blocked",
- "type": "https://developer.nest.com/documentation/cloud/error-messages#blocked",
- "message": "blocked",
- "instance": "bb514046-edc9-4bca-8239-f7a3cfb0925a"
-}
+++ /dev/null
-{
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T20:53:05.338Z",
- "locale": "en-US",
- "name": "Downstairs",
- "name_long": "Downstairs Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
- "where_name": "Downstairs"
-}
+++ /dev/null
-{
- "smoke_co_alarms": [
- "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
- "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
- "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
- "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
- ],
- "name": "Home",
- "country_code": "US",
- "postal_code": "98056",
- "time_zone": "America/Los_Angeles",
- "away": "home",
- "thermostats": [
- "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
- ],
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "rhr_enrollment": false,
- "co_alarm_state": "ok",
- "smoke_alarm_state": "ok",
- "eta_begin": "2017-02-02T03:10:08.000Z",
- "wwn_security_state": "ok",
- "wheres": {
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg",
- "name": "Basement"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ",
- "name": "Bedroom"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw",
- "name": "Den"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g",
- "name": "Dining Room"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
- "name": "Downstairs"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg",
- "name": "Entryway"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA",
- "name": "Family Room"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw",
- "name": "Hallway"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA",
- "name": "Kids Room"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA",
- "name": "Kitchen"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
- "name": "Living Room"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw",
- "name": "Master Bedroom"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q",
- "name": "Office"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA",
- "name": "Upstairs"
- },
- "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ": {
- "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ",
- "name": "Downstairs Kitchen"
- },
- "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ": {
- "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ",
- "name": "Garage"
- },
- "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ": {
- "where_id": "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ",
- "name": "Frog"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA",
- "name": "Backyard"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA",
- "name": "Driveway"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g",
- "name": "Front Yard"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ": {
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ",
- "name": "Outside"
- }
- },
- "cameras": [
- "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
- "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
- ]
-}
+++ /dev/null
-{
- "ambient_temperature_c": 19.0,
- "ambient_temperature_f": 66,
- "away_temperature_high_c": 24.0,
- "away_temperature_high_f": 76,
- "away_temperature_low_c": 12.5,
- "away_temperature_low_f": 55,
- "can_cool": false,
- "can_heat": true,
- "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV",
- "eco_temperature_high_c": 24.0,
- "eco_temperature_high_f": 76,
- "eco_temperature_low_c": 12.5,
- "eco_temperature_low_f": 55,
- "fan_timer_active": false,
- "fan_timer_duration": 15,
- "fan_timer_timeout": "1970-01-01T00:00:00.000Z",
- "has_fan": true,
- "has_leaf": true,
- "humidity": 25,
- "hvac_mode": "heat",
- "hvac_state": "off",
- "is_locked": false,
- "is_online": true,
- "is_using_emergency_heat": false,
- "label": "Living Room",
- "last_connection": "2017-02-02T21:00:06.000Z",
- "locale": "en-GB",
- "locked_temp_max_c": 22.0,
- "locked_temp_max_f": 72,
- "locked_temp_min_c": 20.0,
- "locked_temp_min_f": 68,
- "name": "Living Room (Living Room)",
- "name_long": "Living Room Thermostat (Living Room)",
- "previous_hvac_mode": "",
- "software_version": "5.6-7",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "sunlight_correction_active": false,
- "sunlight_correction_enabled": true,
- "target_temperature_c": 15.5,
- "target_temperature_f": 60,
- "target_temperature_high_c": 24.0,
- "target_temperature_high_f": 75,
- "target_temperature_low_c": 20.0,
- "target_temperature_low_f": 68,
- "temperature_scale": "C",
- "time_to_target": "~0",
- "time_to_target_training": "ready",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
- "where_name": "Living Room"
-}
+++ /dev/null
-{
- "devices": {
- "cameras": {
- "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ": {
- "app_url": "https://camera_app_url",
- "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
- "is_audio_input_enabled": true,
- "is_online": false,
- "is_public_share_enabled": false,
- "is_streaming": false,
- "is_video_history_enabled": false,
- "last_event": {
- "activity_zone_ids": [
- "id1",
- "id2"
- ],
- "animated_image_url": "https://last_event_animated_image_url",
- "app_url": "https://last_event_app_url",
- "end_time": "2017-01-22T07:40:38.680Z",
- "has_motion": true,
- "has_person": false,
- "has_sound": false,
- "image_url": "https://last_event_image_url",
- "start_time": "2017-01-22T07:40:19.020Z",
- "urls_expire_time": "2017-02-05T07:40:19.020Z",
- "web_url": "https://last_event_web_url"
- },
- "last_is_online_change": "2017-01-22T08:19:20.000Z",
- "name": "Upstairs",
- "name_long": "Upstairs Camera",
- "snapshot_url": "https://camera_snapshot_url",
- "software_version": "205-600052",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "web_url": "https://camera_web_url",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
- },
- "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ": {
- "app_url": "nestmobile://cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "device_id": "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ",
- "is_audio_input_enabled": true,
- "is_online": false,
- "is_public_share_enabled": false,
- "is_streaming": false,
- "is_video_history_enabled": false,
- "last_event": {
- "end_time": "2016-11-20T07:02:46.860Z",
- "has_motion": true,
- "has_person": false,
- "has_sound": false,
- "start_time": "2016-11-20T07:02:27.260Z"
- },
- "last_is_online_change": "2016-11-20T07:03:42.000Z",
- "name": "Garage",
- "name_long": "Garage Camera",
- "snapshot_url": "https://www.dropcam.com/api/wwn.get_snapshot/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "software_version": "205-600052",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "web_url": "https://home.nest.com/cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
- }
- },
- "smoke_co_alarms": {
- "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T20:53:05.338Z",
- "locale": "en-US",
- "name": "Downstairs",
- "name_long": "Downstairs Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
- "where_name": "Downstairs"
- },
- "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T20:35:50.051Z",
- "last_manual_test_time": "1970-01-01T00:00:00.000Z",
- "locale": "en-US",
- "name": "Upstairs",
- "name_long": "Upstairs Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA",
- "where_name": "Upstairs"
- },
- "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T11:04:18.804Z",
- "last_manual_test_time": "1970-01-01T00:00:00.000Z",
- "locale": "en-US",
- "name": "Downstairs Kitchen",
- "name_long": "Downstairs Kitchen Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ",
- "where_name": "Downstairs Kitchen"
- },
- "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T13:30:34.187Z",
- "last_manual_test_time": "1970-01-01T00:00:00.000Z",
- "locale": "en-US",
- "name": "Living Room",
- "name_long": "Living Room Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
- "where_name": "Living Room"
- }
- },
- "thermostats": {
- "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV": {
- "ambient_temperature_c": 19.0,
- "ambient_temperature_f": 66,
- "away_temperature_high_c": 24.0,
- "away_temperature_high_f": 76,
- "away_temperature_low_c": 12.5,
- "away_temperature_low_f": 55,
- "can_cool": false,
- "can_heat": true,
- "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV",
- "eco_temperature_high_c": 24.0,
- "eco_temperature_high_f": 76,
- "eco_temperature_low_c": 12.5,
- "eco_temperature_low_f": 55,
- "fan_timer_active": false,
- "fan_timer_duration": 15,
- "fan_timer_timeout": "1970-01-01T00:00:00.000Z",
- "has_fan": true,
- "has_leaf": true,
- "humidity": 25,
- "hvac_mode": "heat",
- "hvac_state": "off",
- "is_locked": false,
- "is_online": true,
- "is_using_emergency_heat": false,
- "label": "Living Room",
- "last_connection": "2017-02-02T21:00:06.000Z",
- "locale": "en-GB",
- "locked_temp_max_c": 22.0,
- "locked_temp_max_f": 72,
- "locked_temp_min_c": 20.0,
- "locked_temp_min_f": 68,
- "name": "Living Room (Living Room)",
- "name_long": "Living Room Thermostat (Living Room)",
- "previous_hvac_mode": "",
- "software_version": "5.6-7",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "sunlight_correction_active": false,
- "sunlight_correction_enabled": true,
- "target_temperature_c": 15.5,
- "target_temperature_f": 60,
- "target_temperature_high_c": 24.0,
- "target_temperature_high_f": 75,
- "target_temperature_low_c": 20.0,
- "target_temperature_low_f": 68,
- "temperature_scale": "C",
- "time_to_target": "~0",
- "time_to_target_training": "ready",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
- "where_name": "Living Room"
- }
- }
- },
- "metadata": {
- "access_token": "c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "client_version": 1
- },
- "structures": {
- "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A": {
- "away": "home",
- "cameras": [
- "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
- "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
- ],
- "co_alarm_state": "ok",
- "country_code": "US",
- "eta_begin": "2017-02-02T03:10:08.000Z",
- "name": "Home",
- "postal_code": "98056",
- "rhr_enrollment": false,
- "smoke_alarm_state": "ok",
- "smoke_co_alarms": [
- "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
- "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
- "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
- "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
- ],
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "thermostats": [
- "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
- ],
- "time_zone": "America/Los_Angeles",
- "wheres": {
- "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ": {
- "name": "Downstairs Kitchen",
- "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ"
- },
- "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ": {
- "name": "Frog",
- "where_id": "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ"
- },
- "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ": {
- "name": "Garage",
- "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA": {
- "name": "Family Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA": {
- "name": "Kitchen",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw": {
- "name": "Hallway",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg": {
- "name": "Basement",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA": {
- "name": "Kids Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw": {
- "name": "Master Bedroom",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg": {
- "name": "Downstairs",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA": {
- "name": "Driveway",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw": {
- "name": "Den",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ": {
- "name": "Bedroom",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg": {
- "name": "Entryway",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA": {
- "name": "Upstairs",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw": {
- "name": "Living Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ": {
- "name": "Outside",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g": {
- "name": "Dining Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA": {
- "name": "Backyard",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q": {
- "name": "Office",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g": {
- "name": "Front Yard",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g"
- }
- },
- "wwn_security_state": "ok"
- }
- }
-}
+++ /dev/null
-{
- "path": "/",
- "data": {
- }
-}
+++ /dev/null
-{
- "path": "/",
- "data": {
- "devices": {
- "cameras": {
- "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ": {
- "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ"
- },
- "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ": {
- "device_id": "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
- }
- },
- "smoke_co_alarms": {
- "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV": {
- "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
- },
- "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV": {
- "device_id": "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV"
- },
- "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV": {
- "device_id": "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV"
- },
- "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV": {
- "device_id": "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV"
- }
- },
- "thermostats": {
- "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV": {
- "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
- },
- "OTQoylk2h5Ld3cfpm3esR0qx-iQr8PMV": {
- "device_id": "OTQoylk2h5Ld3cfpm3esR0qx-iQr8PMV"
- }
- }
- },
- "structures": {
- "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A": {
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A"
- },
- "SylKI7puaWd56ILAcJ46LzmtdZc3L4wGzScs8yLc5zccJofBIW9KTJ": {
- "structure_id": "SylKI7puaWd56ILAcJ46LzmtdZc3L4wGzScs8yLc5zccJofBIW9KTJ"
- }
- }
- }
-}
+++ /dev/null
-{
- "path": "/",
- "data": {
- "devices": {
- "cameras": {
- "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ": {
- "app_url": "https://camera_app_url",
- "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
- "is_audio_input_enabled": true,
- "is_online": true,
- "is_public_share_enabled": false,
- "is_streaming": false,
- "is_video_history_enabled": false,
- "last_event": {
- "activity_zone_ids": [
- "id1",
- "id2"
- ],
- "animated_image_url": "https://last_event_animated_image_url",
- "app_url": "https://last_event_app_url",
- "end_time": "2017-01-22T07:40:38.680Z",
- "has_motion": true,
- "has_person": false,
- "has_sound": false,
- "image_url": "https://last_event_image_url",
- "start_time": "2017-01-22T07:40:19.020Z",
- "urls_expire_time": "2017-02-05T07:40:19.020Z",
- "web_url": "https://last_event_web_url"
- },
- "last_is_online_change": "2017-01-22T08:19:20.000Z",
- "name": "Upstairs",
- "name_long": "Upstairs Camera",
- "public_share_url": "https://camera_public_share_url",
- "snapshot_url": "https://camera_snapshot_url",
- "software_version": "205-600052",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "web_url": "https://camera_web_url",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
- },
- "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ": {
- "app_url": "nestmobile://cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "device_id": "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ",
- "is_audio_input_enabled": true,
- "is_online": false,
- "is_public_share_enabled": false,
- "is_streaming": false,
- "is_video_history_enabled": false,
- "last_event": {
- "end_time": "2016-11-20T07:02:46.860Z",
- "has_motion": true,
- "has_person": false,
- "has_sound": false,
- "start_time": "2016-11-20T07:02:27.260Z"
- },
- "last_is_online_change": "2016-11-20T07:03:42.000Z",
- "name": "Garage",
- "name_long": "Garage Camera",
- "snapshot_url": "https://www.dropcam.com/api/wwn.get_snapshot/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "software_version": "205-600052",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "web_url": "https://home.nest.com/cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
- }
- },
- "smoke_co_alarms": {
- "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T20:53:05.338Z",
- "last_manual_test_time": "2016-10-31T23:59:59.000Z",
- "locale": "en-US",
- "name": "Downstairs",
- "name_long": "Downstairs Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
- "where_name": "Downstairs"
- },
- "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T20:35:50.051Z",
- "last_manual_test_time": "1970-01-01T00:00:00.000Z",
- "locale": "en-US",
- "name": "Upstairs",
- "name_long": "Upstairs Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA",
- "where_name": "Upstairs"
- },
- "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T11:04:18.804Z",
- "last_manual_test_time": "1970-01-01T00:00:00.000Z",
- "locale": "en-US",
- "name": "Downstairs Kitchen",
- "name_long": "Downstairs Kitchen Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ",
- "where_name": "Downstairs Kitchen"
- },
- "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV": {
- "battery_health": "ok",
- "co_alarm_state": "ok",
- "device_id": "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
- "is_manual_test_active": false,
- "is_online": true,
- "last_connection": "2017-02-02T13:30:34.187Z",
- "last_manual_test_time": "1970-01-01T00:00:00.000Z",
- "locale": "en-US",
- "name": "Living Room",
- "name_long": "Living Room Nest Protect",
- "smoke_alarm_state": "ok",
- "software_version": "3.1rc9",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "ui_color_state": "green",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
- "where_name": "Living Room"
- }
- },
- "thermostats": {
- "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV": {
- "ambient_temperature_c": 19.0,
- "ambient_temperature_f": 66,
- "away_temperature_high_c": 24.0,
- "away_temperature_high_f": 76,
- "away_temperature_low_c": 12.5,
- "away_temperature_low_f": 55,
- "can_cool": false,
- "can_heat": true,
- "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV",
- "eco_temperature_high_c": 24.0,
- "eco_temperature_high_f": 76,
- "eco_temperature_low_c": 12.5,
- "eco_temperature_low_f": 55,
- "fan_timer_active": false,
- "fan_timer_duration": 15,
- "fan_timer_timeout": "1970-01-01T00:00:00.000Z",
- "has_fan": true,
- "has_leaf": true,
- "humidity": 25,
- "hvac_mode": "heat",
- "hvac_state": "off",
- "is_locked": false,
- "is_online": true,
- "is_using_emergency_heat": false,
- "label": "Living Room",
- "last_connection": "2017-02-02T21:00:06.000Z",
- "locale": "en-GB",
- "locked_temp_max_c": 22.0,
- "locked_temp_max_f": 72,
- "locked_temp_min_c": 20.0,
- "locked_temp_min_f": 68,
- "name": "Living Room (Living Room)",
- "name_long": "Living Room Thermostat (Living Room)",
- "previous_hvac_mode": "",
- "software_version": "5.6-7",
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "sunlight_correction_active": false,
- "sunlight_correction_enabled": true,
- "target_temperature_c": 15.5,
- "target_temperature_f": 60,
- "target_temperature_high_c": 24.0,
- "target_temperature_high_f": 75,
- "target_temperature_low_c": 20.0,
- "target_temperature_low_f": 68,
- "temperature_scale": "C",
- "time_to_target": "~0",
- "time_to_target_training": "ready",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
- "where_name": "Living Room"
- }
- }
- },
- "metadata": {
- "access_token": "c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
- "client_version": 1
- },
- "structures": {
- "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A": {
- "away": "home",
- "cameras": [
- "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
- "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
- ],
- "co_alarm_state": "ok",
- "country_code": "US",
- "eta_begin": "2017-02-02T03:10:08.000Z",
- "name": "Home",
- "peak_period_end_time": "2017-07-01T01:03:08.400Z",
- "peak_period_start_time": "2017-06-01T13:31:10.870Z",
- "postal_code": "98056",
- "rhr_enrollment": false,
- "smoke_alarm_state": "ok",
- "smoke_co_alarms": [
- "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
- "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
- "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
- "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
- ],
- "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
- "thermostats": [
- "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
- ],
- "time_zone": "America/Los_Angeles",
- "wheres": {
- "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ": {
- "name": "Downstairs Kitchen",
- "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ"
- },
- "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ": {
- "name": "Frog",
- "where_id": "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ"
- },
- "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ": {
- "name": "Garage",
- "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA": {
- "name": "Family Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA": {
- "name": "Kitchen",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw": {
- "name": "Hallway",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg": {
- "name": "Basement",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA": {
- "name": "Kids Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw": {
- "name": "Master Bedroom",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg": {
- "name": "Downstairs",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA": {
- "name": "Driveway",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw": {
- "name": "Den",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ": {
- "name": "Bedroom",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg": {
- "name": "Entryway",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA": {
- "name": "Upstairs",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw": {
- "name": "Living Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ": {
- "name": "Outside",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g": {
- "name": "Dining Room",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA": {
- "name": "Backyard",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q": {
- "name": "Office",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q"
- },
- "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g": {
- "name": "Front Yard",
- "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g"
- }
- },
- "wwn_security_state": "ok"
- }
- }
- }
-}
--- /dev/null
+{
+ "access_token": "access_token",
+ "expires_in": 315360000
+}
--- /dev/null
+{
+ "app_url": "https://camera_app_url",
+ "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
+ "is_audio_input_enabled": true,
+ "is_online": true,
+ "is_public_share_enabled": false,
+ "is_streaming": false,
+ "is_video_history_enabled": false,
+ "last_event": {
+ "activity_zone_ids": [
+ "id1",
+ "id2"
+ ],
+ "animated_image_url": "https://last_event_animated_image_url",
+ "app_url": "https://last_event_app_url",
+ "end_time": "2017-01-22T07:40:38.680Z",
+ "has_motion": true,
+ "has_person": false,
+ "has_sound": false,
+ "image_url": "https://last_event_image_url",
+ "start_time": "2017-01-22T07:40:19.020Z",
+ "urls_expire_time": "2017-02-05T07:40:19.020Z",
+ "web_url": "https://last_event_web_url"
+ },
+ "last_is_online_change": "2017-01-22T08:19:20.000Z",
+ "name": "Upstairs",
+ "name_long": "Upstairs Camera",
+ "snapshot_url": "https://camera_snapshot_url",
+ "software_version": "205-600052",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "web_url": "https://camera_web_url",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
+}
--- /dev/null
+{
+ "error": "blocked",
+ "type": "https://developer.nest.com/documentation/cloud/error-messages#blocked",
+ "message": "blocked",
+ "instance": "bb514046-edc9-4bca-8239-f7a3cfb0925a"
+}
--- /dev/null
+{
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T20:53:05.338Z",
+ "locale": "en-US",
+ "name": "Downstairs",
+ "name_long": "Downstairs Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
+ "where_name": "Downstairs"
+}
--- /dev/null
+{
+ "smoke_co_alarms": [
+ "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
+ "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
+ "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
+ "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
+ ],
+ "name": "Home",
+ "country_code": "US",
+ "postal_code": "98056",
+ "time_zone": "America/Los_Angeles",
+ "away": "home",
+ "thermostats": [
+ "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
+ ],
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "rhr_enrollment": false,
+ "co_alarm_state": "ok",
+ "smoke_alarm_state": "ok",
+ "eta_begin": "2017-02-02T03:10:08.000Z",
+ "wwn_security_state": "ok",
+ "wheres": {
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg",
+ "name": "Basement"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ",
+ "name": "Bedroom"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw",
+ "name": "Den"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g",
+ "name": "Dining Room"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
+ "name": "Downstairs"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg",
+ "name": "Entryway"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA",
+ "name": "Family Room"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw",
+ "name": "Hallway"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA",
+ "name": "Kids Room"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA",
+ "name": "Kitchen"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
+ "name": "Living Room"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw",
+ "name": "Master Bedroom"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q",
+ "name": "Office"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA",
+ "name": "Upstairs"
+ },
+ "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ": {
+ "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ",
+ "name": "Downstairs Kitchen"
+ },
+ "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ": {
+ "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ",
+ "name": "Garage"
+ },
+ "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ": {
+ "where_id": "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ",
+ "name": "Frog"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA",
+ "name": "Backyard"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA",
+ "name": "Driveway"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g",
+ "name": "Front Yard"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ": {
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ",
+ "name": "Outside"
+ }
+ },
+ "cameras": [
+ "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
+ "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
+ ]
+}
--- /dev/null
+{
+ "ambient_temperature_c": 19.0,
+ "ambient_temperature_f": 66,
+ "away_temperature_high_c": 24.0,
+ "away_temperature_high_f": 76,
+ "away_temperature_low_c": 12.5,
+ "away_temperature_low_f": 55,
+ "can_cool": false,
+ "can_heat": true,
+ "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV",
+ "eco_temperature_high_c": 24.0,
+ "eco_temperature_high_f": 76,
+ "eco_temperature_low_c": 12.5,
+ "eco_temperature_low_f": 55,
+ "fan_timer_active": false,
+ "fan_timer_duration": 15,
+ "fan_timer_timeout": "1970-01-01T00:00:00.000Z",
+ "has_fan": true,
+ "has_leaf": true,
+ "humidity": 25,
+ "hvac_mode": "heat",
+ "hvac_state": "off",
+ "is_locked": false,
+ "is_online": true,
+ "is_using_emergency_heat": false,
+ "label": "Living Room",
+ "last_connection": "2017-02-02T21:00:06.000Z",
+ "locale": "en-GB",
+ "locked_temp_max_c": 22.0,
+ "locked_temp_max_f": 72,
+ "locked_temp_min_c": 20.0,
+ "locked_temp_min_f": 68,
+ "name": "Living Room (Living Room)",
+ "name_long": "Living Room Thermostat (Living Room)",
+ "previous_hvac_mode": "",
+ "software_version": "5.6-7",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "sunlight_correction_active": false,
+ "sunlight_correction_enabled": true,
+ "target_temperature_c": 15.5,
+ "target_temperature_f": 60,
+ "target_temperature_high_c": 24.0,
+ "target_temperature_high_f": 75,
+ "target_temperature_low_c": 20.0,
+ "target_temperature_low_f": 68,
+ "temperature_scale": "C",
+ "time_to_target": "~0",
+ "time_to_target_training": "ready",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
+ "where_name": "Living Room"
+}
--- /dev/null
+{
+ "devices": {
+ "cameras": {
+ "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ": {
+ "app_url": "https://camera_app_url",
+ "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
+ "is_audio_input_enabled": true,
+ "is_online": false,
+ "is_public_share_enabled": false,
+ "is_streaming": false,
+ "is_video_history_enabled": false,
+ "last_event": {
+ "activity_zone_ids": [
+ "id1",
+ "id2"
+ ],
+ "animated_image_url": "https://last_event_animated_image_url",
+ "app_url": "https://last_event_app_url",
+ "end_time": "2017-01-22T07:40:38.680Z",
+ "has_motion": true,
+ "has_person": false,
+ "has_sound": false,
+ "image_url": "https://last_event_image_url",
+ "start_time": "2017-01-22T07:40:19.020Z",
+ "urls_expire_time": "2017-02-05T07:40:19.020Z",
+ "web_url": "https://last_event_web_url"
+ },
+ "last_is_online_change": "2017-01-22T08:19:20.000Z",
+ "name": "Upstairs",
+ "name_long": "Upstairs Camera",
+ "snapshot_url": "https://camera_snapshot_url",
+ "software_version": "205-600052",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "web_url": "https://camera_web_url",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
+ },
+ "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ": {
+ "app_url": "nestmobile://cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "device_id": "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ",
+ "is_audio_input_enabled": true,
+ "is_online": false,
+ "is_public_share_enabled": false,
+ "is_streaming": false,
+ "is_video_history_enabled": false,
+ "last_event": {
+ "end_time": "2016-11-20T07:02:46.860Z",
+ "has_motion": true,
+ "has_person": false,
+ "has_sound": false,
+ "start_time": "2016-11-20T07:02:27.260Z"
+ },
+ "last_is_online_change": "2016-11-20T07:03:42.000Z",
+ "name": "Garage",
+ "name_long": "Garage Camera",
+ "snapshot_url": "https://www.dropcam.com/api/wwn.get_snapshot/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "software_version": "205-600052",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "web_url": "https://home.nest.com/cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
+ }
+ },
+ "smoke_co_alarms": {
+ "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T20:53:05.338Z",
+ "locale": "en-US",
+ "name": "Downstairs",
+ "name_long": "Downstairs Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
+ "where_name": "Downstairs"
+ },
+ "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T20:35:50.051Z",
+ "last_manual_test_time": "1970-01-01T00:00:00.000Z",
+ "locale": "en-US",
+ "name": "Upstairs",
+ "name_long": "Upstairs Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA",
+ "where_name": "Upstairs"
+ },
+ "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T11:04:18.804Z",
+ "last_manual_test_time": "1970-01-01T00:00:00.000Z",
+ "locale": "en-US",
+ "name": "Downstairs Kitchen",
+ "name_long": "Downstairs Kitchen Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ",
+ "where_name": "Downstairs Kitchen"
+ },
+ "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T13:30:34.187Z",
+ "last_manual_test_time": "1970-01-01T00:00:00.000Z",
+ "locale": "en-US",
+ "name": "Living Room",
+ "name_long": "Living Room Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
+ "where_name": "Living Room"
+ }
+ },
+ "thermostats": {
+ "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV": {
+ "ambient_temperature_c": 19.0,
+ "ambient_temperature_f": 66,
+ "away_temperature_high_c": 24.0,
+ "away_temperature_high_f": 76,
+ "away_temperature_low_c": 12.5,
+ "away_temperature_low_f": 55,
+ "can_cool": false,
+ "can_heat": true,
+ "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV",
+ "eco_temperature_high_c": 24.0,
+ "eco_temperature_high_f": 76,
+ "eco_temperature_low_c": 12.5,
+ "eco_temperature_low_f": 55,
+ "fan_timer_active": false,
+ "fan_timer_duration": 15,
+ "fan_timer_timeout": "1970-01-01T00:00:00.000Z",
+ "has_fan": true,
+ "has_leaf": true,
+ "humidity": 25,
+ "hvac_mode": "heat",
+ "hvac_state": "off",
+ "is_locked": false,
+ "is_online": true,
+ "is_using_emergency_heat": false,
+ "label": "Living Room",
+ "last_connection": "2017-02-02T21:00:06.000Z",
+ "locale": "en-GB",
+ "locked_temp_max_c": 22.0,
+ "locked_temp_max_f": 72,
+ "locked_temp_min_c": 20.0,
+ "locked_temp_min_f": 68,
+ "name": "Living Room (Living Room)",
+ "name_long": "Living Room Thermostat (Living Room)",
+ "previous_hvac_mode": "",
+ "software_version": "5.6-7",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "sunlight_correction_active": false,
+ "sunlight_correction_enabled": true,
+ "target_temperature_c": 15.5,
+ "target_temperature_f": 60,
+ "target_temperature_high_c": 24.0,
+ "target_temperature_high_f": 75,
+ "target_temperature_low_c": 20.0,
+ "target_temperature_low_f": 68,
+ "temperature_scale": "C",
+ "time_to_target": "~0",
+ "time_to_target_training": "ready",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
+ "where_name": "Living Room"
+ }
+ }
+ },
+ "metadata": {
+ "access_token": "c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "client_version": 1
+ },
+ "structures": {
+ "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A": {
+ "away": "home",
+ "cameras": [
+ "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
+ "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
+ ],
+ "co_alarm_state": "ok",
+ "country_code": "US",
+ "eta_begin": "2017-02-02T03:10:08.000Z",
+ "name": "Home",
+ "postal_code": "98056",
+ "rhr_enrollment": false,
+ "smoke_alarm_state": "ok",
+ "smoke_co_alarms": [
+ "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
+ "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
+ "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
+ "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
+ ],
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "thermostats": [
+ "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
+ ],
+ "time_zone": "America/Los_Angeles",
+ "wheres": {
+ "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ": {
+ "name": "Downstairs Kitchen",
+ "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ"
+ },
+ "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ": {
+ "name": "Frog",
+ "where_id": "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ"
+ },
+ "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ": {
+ "name": "Garage",
+ "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA": {
+ "name": "Family Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA": {
+ "name": "Kitchen",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw": {
+ "name": "Hallway",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg": {
+ "name": "Basement",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA": {
+ "name": "Kids Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw": {
+ "name": "Master Bedroom",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg": {
+ "name": "Downstairs",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA": {
+ "name": "Driveway",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw": {
+ "name": "Den",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ": {
+ "name": "Bedroom",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg": {
+ "name": "Entryway",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA": {
+ "name": "Upstairs",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw": {
+ "name": "Living Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ": {
+ "name": "Outside",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g": {
+ "name": "Dining Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA": {
+ "name": "Backyard",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q": {
+ "name": "Office",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g": {
+ "name": "Front Yard",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g"
+ }
+ },
+ "wwn_security_state": "ok"
+ }
+ }
+}
--- /dev/null
+{
+ "path": "/",
+ "data": {
+ }
+}
--- /dev/null
+{
+ "path": "/",
+ "data": {
+ "devices": {
+ "cameras": {
+ "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ": {
+ "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ"
+ },
+ "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ": {
+ "device_id": "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
+ }
+ },
+ "smoke_co_alarms": {
+ "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV": {
+ "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
+ },
+ "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV": {
+ "device_id": "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV"
+ },
+ "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV": {
+ "device_id": "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV"
+ },
+ "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV": {
+ "device_id": "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV"
+ }
+ },
+ "thermostats": {
+ "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV": {
+ "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
+ },
+ "OTQoylk2h5Ld3cfpm3esR0qx-iQr8PMV": {
+ "device_id": "OTQoylk2h5Ld3cfpm3esR0qx-iQr8PMV"
+ }
+ }
+ },
+ "structures": {
+ "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A": {
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A"
+ },
+ "SylKI7puaWd56ILAcJ46LzmtdZc3L4wGzScs8yLc5zccJofBIW9KTJ": {
+ "structure_id": "SylKI7puaWd56ILAcJ46LzmtdZc3L4wGzScs8yLc5zccJofBIW9KTJ"
+ }
+ }
+ }
+}
--- /dev/null
+{
+ "path": "/",
+ "data": {
+ "devices": {
+ "cameras": {
+ "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ": {
+ "app_url": "https://camera_app_url",
+ "device_id": "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
+ "is_audio_input_enabled": true,
+ "is_online": true,
+ "is_public_share_enabled": false,
+ "is_streaming": false,
+ "is_video_history_enabled": false,
+ "last_event": {
+ "activity_zone_ids": [
+ "id1",
+ "id2"
+ ],
+ "animated_image_url": "https://last_event_animated_image_url",
+ "app_url": "https://last_event_app_url",
+ "end_time": "2017-01-22T07:40:38.680Z",
+ "has_motion": true,
+ "has_person": false,
+ "has_sound": false,
+ "image_url": "https://last_event_image_url",
+ "start_time": "2017-01-22T07:40:19.020Z",
+ "urls_expire_time": "2017-02-05T07:40:19.020Z",
+ "web_url": "https://last_event_web_url"
+ },
+ "last_is_online_change": "2017-01-22T08:19:20.000Z",
+ "name": "Upstairs",
+ "name_long": "Upstairs Camera",
+ "public_share_url": "https://camera_public_share_url",
+ "snapshot_url": "https://camera_snapshot_url",
+ "software_version": "205-600052",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "web_url": "https://camera_web_url",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
+ },
+ "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ": {
+ "app_url": "nestmobile://cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "device_id": "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ",
+ "is_audio_input_enabled": true,
+ "is_online": false,
+ "is_public_share_enabled": false,
+ "is_streaming": false,
+ "is_video_history_enabled": false,
+ "last_event": {
+ "end_time": "2016-11-20T07:02:46.860Z",
+ "has_motion": true,
+ "has_person": false,
+ "has_sound": false,
+ "start_time": "2016-11-20T07:02:27.260Z"
+ },
+ "last_is_online_change": "2016-11-20T07:03:42.000Z",
+ "name": "Garage",
+ "name_long": "Garage Camera",
+ "snapshot_url": "https://www.dropcam.com/api/wwn.get_snapshot/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "software_version": "205-600052",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "web_url": "https://home.nest.com/cameras/CjZWRzdDN0JVNlpmOE9qRWZpem1CQ1Zud251S0hTbk9CSUhnYlFLYTU3eEtKenJ2b2tLX0R6RlESFm9wNVB2NW93NmJ6cUdvMkZQSGUxdEEaNld0Mkl5b2tIR0tKX2FpUVd1SkRnQjc2ejhSWFl3SFFxWXFrSWx2QlpxN1gyeWNqdmRZVjdGQQ?auth=c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
+ }
+ },
+ "smoke_co_alarms": {
+ "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T20:53:05.338Z",
+ "last_manual_test_time": "2016-10-31T23:59:59.000Z",
+ "locale": "en-US",
+ "name": "Downstairs",
+ "name_long": "Downstairs Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg",
+ "where_name": "Downstairs"
+ },
+ "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T20:35:50.051Z",
+ "last_manual_test_time": "1970-01-01T00:00:00.000Z",
+ "locale": "en-US",
+ "name": "Upstairs",
+ "name_long": "Upstairs Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA",
+ "where_name": "Upstairs"
+ },
+ "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T11:04:18.804Z",
+ "last_manual_test_time": "1970-01-01T00:00:00.000Z",
+ "locale": "en-US",
+ "name": "Downstairs Kitchen",
+ "name_long": "Downstairs Kitchen Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ",
+ "where_name": "Downstairs Kitchen"
+ },
+ "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV": {
+ "battery_health": "ok",
+ "co_alarm_state": "ok",
+ "device_id": "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
+ "is_manual_test_active": false,
+ "is_online": true,
+ "last_connection": "2017-02-02T13:30:34.187Z",
+ "last_manual_test_time": "1970-01-01T00:00:00.000Z",
+ "locale": "en-US",
+ "name": "Living Room",
+ "name_long": "Living Room Nest Protect",
+ "smoke_alarm_state": "ok",
+ "software_version": "3.1rc9",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "ui_color_state": "green",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
+ "where_name": "Living Room"
+ }
+ },
+ "thermostats": {
+ "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV": {
+ "ambient_temperature_c": 19.0,
+ "ambient_temperature_f": 66,
+ "away_temperature_high_c": 24.0,
+ "away_temperature_high_f": 76,
+ "away_temperature_low_c": 12.5,
+ "away_temperature_low_f": 55,
+ "can_cool": false,
+ "can_heat": true,
+ "device_id": "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV",
+ "eco_temperature_high_c": 24.0,
+ "eco_temperature_high_f": 76,
+ "eco_temperature_low_c": 12.5,
+ "eco_temperature_low_f": 55,
+ "fan_timer_active": false,
+ "fan_timer_duration": 15,
+ "fan_timer_timeout": "1970-01-01T00:00:00.000Z",
+ "has_fan": true,
+ "has_leaf": true,
+ "humidity": 25,
+ "hvac_mode": "heat",
+ "hvac_state": "off",
+ "is_locked": false,
+ "is_online": true,
+ "is_using_emergency_heat": false,
+ "label": "Living Room",
+ "last_connection": "2017-02-02T21:00:06.000Z",
+ "locale": "en-GB",
+ "locked_temp_max_c": 22.0,
+ "locked_temp_max_f": 72,
+ "locked_temp_min_c": 20.0,
+ "locked_temp_min_f": 68,
+ "name": "Living Room (Living Room)",
+ "name_long": "Living Room Thermostat (Living Room)",
+ "previous_hvac_mode": "",
+ "software_version": "5.6-7",
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "sunlight_correction_active": false,
+ "sunlight_correction_enabled": true,
+ "target_temperature_c": 15.5,
+ "target_temperature_f": 60,
+ "target_temperature_high_c": 24.0,
+ "target_temperature_high_f": 75,
+ "target_temperature_low_c": 20.0,
+ "target_temperature_low_f": 68,
+ "temperature_scale": "C",
+ "time_to_target": "~0",
+ "time_to_target_training": "ready",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw",
+ "where_name": "Living Room"
+ }
+ }
+ },
+ "metadata": {
+ "access_token": "c.eQ5QBBPiFOTNzPHbmZPcE9yPZ7GayzLusifgQR2DQRFNyUS9ESvlhJF0D7vG8Y0TFV39zX1vIOsWrv8RKCMrFepNUb9FqHEboa4MtWLUsGb4tD9oBh0jrV4HooJUmz5sVA5KZR0dkxyLYyPc",
+ "client_version": 1
+ },
+ "structures": {
+ "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A": {
+ "away": "home",
+ "cameras": [
+ "_LK8j9rRXwCKEBOtDo7JskNxzWfHBOIm3CLouCT3FQZzrvokK_DzFQ",
+ "VG7C7BU6Zf8OjEfizmBCVnwnuKHSnOBIHgbQKa57xKJzrvokK_DzFQ"
+ ],
+ "co_alarm_state": "ok",
+ "country_code": "US",
+ "eta_begin": "2017-02-02T03:10:08.000Z",
+ "name": "Home",
+ "peak_period_end_time": "2017-07-01T01:03:08.400Z",
+ "peak_period_start_time": "2017-06-01T13:31:10.870Z",
+ "postal_code": "98056",
+ "rhr_enrollment": false,
+ "smoke_alarm_state": "ok",
+ "smoke_co_alarms": [
+ "p1b1oySOcs-OJHIgmgeMkHOu-iQr8PMV",
+ "p1b1oySOcs8Qu7IAJVrQ7XOu-iQr8PMV",
+ "p1b1oySOcs8W9WwaNu80oXOu-iQr8PMV",
+ "p1b1oySOcs_sbi4iczruW3Ou-iQr8PMV"
+ ],
+ "structure_id": "ysCnsCaq1pQwKUPP9H4AqE943C1XtLin3x6uCVN5Qh09IDyTg7Ey5A",
+ "thermostats": [
+ "G1jouHN5yl6mXFaQw5iGwXOu-iQr8PMV"
+ ],
+ "time_zone": "America/Los_Angeles",
+ "wheres": {
+ "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ": {
+ "name": "Downstairs Kitchen",
+ "where_id": "6UAWzz8czKpFrH6EK3AcjDiTjbRgts8x5MJxEnn1yKKQpYTBO7n2UQ"
+ },
+ "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ": {
+ "name": "Frog",
+ "where_id": "8tH6YiXUAQDZFLD6AgMmQ14Sc5wTG0NxKfabPY0XKrqc47t3uSDZvQ"
+ },
+ "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ": {
+ "name": "Garage",
+ "where_id": "qpWvTu89Knhn6GRFM-VtGoE4KYwbzbJg9INR6WyPfhW1EJ04GRyYbQ"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA": {
+ "name": "Family Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIAYVvcpN1cOA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA": {
+ "name": "Kitchen",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB2f05cPKRBA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw": {
+ "name": "Hallway",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIB7GULj0y7Rw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg": {
+ "name": "Basement",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIYpqdaXnYjUg"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA": {
+ "name": "Kids Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIbTUmML4Q6xA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw": {
+ "name": "Master Bedroom",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIebdVzhA62Iw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg": {
+ "name": "Downstairs",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsIm5E0NfJPeeg"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA": {
+ "name": "Driveway",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJv12iEHQ0hxA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw": {
+ "name": "Den",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsJyRQEOtmKqkw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ": {
+ "name": "Bedroom",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK-nCnEjccnMQ"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg": {
+ "name": "Entryway",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsK2kdsXRP3IFg"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA": {
+ "name": "Upstairs",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKCxvyZfxNpKA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw": {
+ "name": "Living Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKQrCrjN0yXiw"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ": {
+ "name": "Outside",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKR8TWb9hTptQ"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g": {
+ "name": "Dining Room",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKZphUIYeW39g"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA": {
+ "name": "Backyard",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKfexoqPTcUVA"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q": {
+ "name": "Office",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsKtUyRb3je64Q"
+ },
+ "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g": {
+ "name": "Front Yard",
+ "where_id": "z8fK075vJJPPWnXxLx1m3GskRSZQ64iQydB59k-UPsLRu9lIioI47g"
+ }
+ },
+ "wwn_security_state": "ok"
+ }
+ }
+ }
+}