diff --git a/CODEOWNERS b/CODEOWNERS index 51be770aa98c6..ca331880cf9d5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -165,6 +165,7 @@ /bundles/org.openhab.binding.meteoblue/ @9037568 /bundles/org.openhab.binding.meteostick/ @cdjackson /bundles/org.openhab.binding.miele/ @kgoderis +/bundles/org.openhab.binding.mielecloud/ @BjoernLange /bundles/org.openhab.binding.mihome/ @pboos /bundles/org.openhab.binding.miio/ @marcelrv /bundles/org.openhab.binding.milight/ @davidgraeff diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index a3396a3a3ddf6..b183a255f0598 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -811,6 +811,11 @@ org.openhab.binding.miele ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.mielecloud + ${project.version} + org.openhab.addons.bundles org.openhab.binding.mihome diff --git a/bundles/org.openhab.binding.mielecloud/NOTICE b/bundles/org.openhab.binding.mielecloud/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.mielecloud/README.md b/bundles/org.openhab.binding.mielecloud/README.md new file mode 100644 index 0000000000000..9da358d69b6bc --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/README.md @@ -0,0 +1,623 @@ +# Miele Cloud Binding + +This binding integrates [Miele@home](https://www.miele.de/brand/smarthome-42801.htm) appliances via a cloud connection. +A Miele cloud account and a set of developer credentials is required to use the binding. +The latter can be requested from the [Miele Developer Portal](https://www.miele.com/f/com/en/register_api.aspx). + +## Supported Things + +Most Miele appliances that directly connect to the cloud via a Wi-Fi module are supported. +Appliances connecting to the XGW3000 gateway via ZigBee are also supported when registered with the cloud account. +However they might be better supported by the [gateway-based Miele binding](https://www.openhab.org/addons/bindings/miele/). +Depending on the age of your appliance the functionality of the binding might be limited. +Appliances from recent generations will support all functionality. + +The following types of appliances are supported: + +| Appliance type | Thing type | +| -------------------------------- | ------------------------ | +| Coffee Machine | `coffee_system` | +| Dishwasher | `dishwasher` | +| Dish Warmer | `dish_warmer` | +| Freezer | `freezer` | +| Fridge | `fridge` | +| Fridge-Freezer Combination | `fridge_freezer` | +| Hob | `hob` | +| Hood | `hood` | +| Microwave Oven | `oven` | +| Oven | `oven` | +| Robotic Vacuum Cleaner | `robotic_vacuum_cleaner` | +| Tumble Dryer | `dryer` | +| Washer Dryer | `washer_dryer` | +| Washing Machine | `washing_machine` | +| Wine Cabinet | `wine_storage` | +| Wine Cabinet Freezer Combination | `wine_storage` | + +## Discovery + +Please take the following steps prior to using the binding. Create a Miele cloud account in the Miele@mobile app for [Android](https://play.google.com/store/apps/details?id=de.miele.infocontrol&hl=en_US) or [iOS](https://apps.apple.com/de/app/miele-mobile/id930406907?l=en) (if not already done). +Afterwards, pair your appliances. +Once your appliances are set up, register at the [Miele Developer Portal](https://www.miele.com/f/com/en/register_api.aspx). +You will receive a pair of client ID and client secret which will be used to pair your Miele cloud account to the Miele cloud openHAB binding. +Keep these credentials to yourself and treat them like a password! +It may take some time until the registration e-mail arrives. + +There is no auto discovery for the Miele cloud account. +The account is paired using OAuth2 with your Miele login and the developer credentials obtained from the [Miele Developer Portal](https://www.miele.com/f/com/en/register_api.aspx). +To pair the account go to the binding's configuration UI at `https:///mielecloud`. +For a standard openHABian Pi installation the address is [https://openhabianpi:8443/mielecloud](https://openhabianpi:8443/mielecloud). +Note that your browser will file a warning that the certificate is self-signed. +This is fine and you can safely continue. +It is also possible to use an unsecured connection for pairing but it is strongly recommended to use a secured connection because your credentials will otherwise be transferred without encryption over the local network. +For more information on this topic, see [Securing access to openHAB](https://www.openhab.org/docs/installation/security.html#encrypted-communication). +For a detailed walk through the account configuration, see [Account Configuration Example](#account-configuration-example). + +Once a Miele account is paired, all supported appliances are automatically discovered as individual things and placed in the inbox. +They can then be paired with your favorite management UI. +As an alternative, the binding configuration UI provides a things-file template per paired account that can be used to pair the appliances. + +## Thing Configuration + +A Miele cloud account needs to be configured to get access to your appliances. +After that appliances can be configured. + +### Account Configuration + +The Miele cloud account must be paired via the binding configuration UI before a bridge that relies on it can be configured in openHAB. +For details on the configuration UI see [Discovery](#discovery) and [Account Configuration Example](#account-configuration-example). +The account serves as a bridge for the things representing your appliances. +On success the configuration assistant will directly configure the account without requiring further actions. +As an alternative, it provides a things-file template. + +The account has the following parameters: + +| Name | Type | Description | +| ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| email | required | E-mail address identifying this account. This exists only to distinguish accounts. If the address is changed after authorization then the account needs to be authorized again. | +| locale | optional | The locale to use for full text channels of things from this account. Possible values are `en`, `de`, `da`, `es`, `fr`, `it`, `nl`, `nb`. Default is `en`. | + + +### Appliance Configuration + +The binding configuration UI will show a things-file template containing things for all supported appliances from the paired account. +This can be used as a starting point for a custom things-file. + +All Miele cloud appliance things have the following parameters: + +| Name | Type | Description | +| ---------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| deviceIdentifier | required | Technical device identifier uniquely identifying the Miele appliance. Use the discovery result or the things-file template to obtain it. | + + +## Channels + +The following table lists all available channels. +See the following chapters for detailed information about which appliance supports which channels. +Depending on the exact appliance configuration not all channels might be supported, e.g. a hob with four plates will only fill the channels for plates 1-4. +Channel ID and channel type ID match unless noted. + +| Channel Type ID | Item Type | Description | Read only | +| ----------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| remote_control_can_be_started | Switch | Indicates if this device can be started remotely. | Yes | +| remote_control_can_be_stopped | Switch | Indicates if this device can be stopped remotely. | Yes | +| remote_control_can_be_paused | Switch | Indicates if this device can be paused remotely. | Yes | +| remote_control_can_be_switched_on | Switch | Indicates if the device can be switched on remotely. | Yes | +| remote_control_can_be_switched_off | Switch | Indicates if the device can be switched off remotely. | Yes | +| remote_control_can_set_program_active | Switch | Indicates if the active program of the device can be set remotely. | Yes | +| spinning_speed | String | The spinning speed of the active program. | Yes | +| spinning_speed_raw | Number | The raw spinning speed of the active program. | Yes | +| program_active | String | The active program of the device. | Yes | +| program_active_raw | Number | The raw active program of the device. | Yes | +| dish_warmer_program_active | String | The active program of the device. | No | +| vacuum_cleaner_program_active | String | The active program of the device. | No | +| program_phase | String | The phase of the active program. | Yes | +| program_phase_raw | Number | The raw phase of the active program. | Yes | +| operation_state | String | The operation state of the device. | Yes | +| operation_state_raw | Number | The raw operation state of the device. | Yes | +| program_start | Switch | Starts the currently selected program. | No | +| program_stop | Switch | Stops the currently selected program. | No | +| program_start_stop | String | Starts or stops the currently selected program. | No | +| program_start_stop_pause | String | Starts, stops or pauses the currently selected program. | No | +| power_state_on_off | String | Switches the device On or Off. | No | +| finish_state | Switch | Indicates whether the most recent program finished. | Yes | +| delayed_start_time | Number | The delayed start time of the selected program. | Yes | +| program_remaining_time | Number | The remaining time of the active program. | Yes | +| program_elapsed_time | Number | The elapsed time of the active program. | Yes | +| program_progress | Number | The progress of the active program. | Yes | +| drying_target | String | The target drying step of the laundry. | Yes | +| drying_target_raw | Number | The raw target drying step of the laundry. | Yes | +| pre_heat_finished | Switch | Indicates whether the pre-heating finished. | Yes | +| temperature_target | Number | The target temperature of the device. | Yes | +| temperature_current | Number | The currently measured temperature of the device. | Yes | +| ventilation_power | String | The current ventilation power of the hood. | Yes | +| ventilation_power_raw | Number | The current raw ventilation power of the hood. | Yes | +| error_state | Switch | Indication flag which signals an error state for the device. | Yes | +| info_state | Switch | Indication flag which signals an information of the device. | Yes | +| fridge_super_cool | Switch | Start the super cooling mode of the fridge. | No | +| freezer_super_freeze | Switch | Start the super freezing mode of the freezer. | No | +| super_cool_can_be_controlled | Switch | Indicates if super cooling can be toggled. | Yes | +| super_freeze_can_be_controlled | Switch | Indicates if super freezing can be toggled | Yes | +| fridge_temperature_target | Number | The target temperature of the fridge. | Yes | +| fridge_temperature_current | Number | The currently measured temperature of the fridge. | Yes | +| freezer_temperature_target | Number | The target temperature of the freezer. | Yes | +| freezer_temperature_current | Number | The currently measured temperature of the freezer. | Yes | +| top_temperature_target | Number | The target temperature of the top area. | Yes | +| top_temperature_current | Number | The currently measured temperature of the top area. | Yes | +| middle_temperature_target | Number | The target temperature of the middle area. | Yes | +| middle_temperature_current | Number | The currently measured temperature of the middle area. | Yes | +| bottom_temperature_target | Number | The target temperature of the bottom area. | Yes | +| bottom_temperature_current | Number | The currently measured temperature of the bottom area. | Yes | +| light_switch | Switch | Indicates if the light of the device is enabled. | No | +| light_can_be_controlled | Switch | Indicates if the light of the device can be controlled. | Yes | +| plate_power_step | String | The power level of the heating plate. | Yes | +| plate_power_step_raw | Number | The raw power level of the heating plate. | Yes | +| door_state | Switch | Indicates if the door of the device is open. | Yes | +| door_alarm | Switch | Indicates if the door alarm of the device is active. | Yes | +| battery_level | Number | The battery level of the robotic vacuum cleaner. | Yes | + +### Coffee System + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- program_active +- program_active_raw +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- finish_state +- power_state_on_off +- program_remaining_time +- program_elapsed_time +- error_state +- info_state +- light_switch +- light_can_be_controlled + +### Dish Warmer + +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- dish_warmer_program_active +- program_active_raw +- operation_state +- operation_state_raw +- power_state_on_off +- finish_state +- program_remaining_time +- program_elapsed_time +- program_progress +- temperature_target +- temperature_current +- error_state +- info_state +- door_state + +### Dishwasher + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- program_active +- program_active_raw +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- program_start_stop +- finish_state +- power_state_on_off +- delayed_start_time +- program_remaining_time +- program_elapsed_time +- program_progress +- error_state +- info_state +- door_state + +### Tumble Dryer + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- program_active +- program_active_raw +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- program_start_stop +- finish_state +- power_state_on_off +- delayed_start_time +- program_remaining_time +- program_elapsed_time +- program_progress +- drying_target +- drying_target_raw +- error_state +- info_state +- light_switch +- light_can_be_controlled +- door_state + +### Freezer + +- operation_state +- operation_state_raw +- error_state +- info_state +- freezer_super_freeze +- super_freeze_can_be_controlled +- freezer_temperature_target +- freezer_temperature_current +- door_state +- door_alarm + +### Fridge + +- operation_state +- operation_state_raw +- error_state +- info_state +- fridge_super_cool +- super_cool_can_be_controlled +- fridge_temperature_target +- fridge_temperature_current +- door_state +- door_alarm + +### Fridge Freezer + +- operation_state +- operation_state_raw +- error_state +- info_state +- fridge_super_cool +- freezer_super_freeze +- super_cool_can_be_controlled +- super_freeze_can_be_controlled +- fridge_temperature_target +- fridge_temperature_current +- freezer_temperature_target +- freezer_temperature_current +- door_state +- door_alarm + +### Hob + +- operation_state +- operation_state_raw +- error_state +- info_state +- plate_1_power_step to plate_6_power_step with channel type ID plate_power_step +- plate_1_power_step_raw to plate_6_power_step_raw with channel type ID plate_power_step_raw + +### Hood + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- power_state_on_off +- ventilation_power +- ventilation_power_raw +- error_state +- info_state +- light_switch +- light_can_be_controlled + +### Oven + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- program_active +- program_active_raw +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- program_start_stop +- finish_state +- power_state_on_off +- delayed_start_time +- program_remaining_time +- program_elapsed_time +- program_progress +- pre_heat_finished +- temperature_target +- temperature_current +- error_state +- info_state +- light_switch +- light_can_be_controlled +- door_state + +### Robotic Vacuum Cleaner + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_paused +- remote_control_can_set_program_active +- vacuum_cleaner_program_active +- program_active_raw +- operation_state +- operation_state_raw +- finish_state +- program_start_stop_pause +- power_state_on_off +- error_state +- info_state +- battery_level + +### Washer Dryer + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- spinning_speed +- spinning_speed_raw +- program_active +- program_active_raw +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- program_start_stop +- finish_state +- power_state_on_off +- delayed_start_time +- program_remaining_time +- program_elapsed_time +- program_progress +- drying_target +- drying_target_raw +- error_state +- info_state +- temperature_target +- light_switch +- light_can_be_controlled +- door_state + +### Washing Machine + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- spinning_speed +- spinning_speed_raw +- program_active +- program_active_raw +- program_phase +- program_phase_raw +- operation_state +- operation_state_raw +- program_start_stop +- finish_state +- power_state_on_off +- delayed_start_time +- program_remaining_time +- program_elapsed_time +- program_progress +- error_state +- info_state +- temperature_target +- light_switch +- light_can_be_controlled +- door_state + +### Wine Storage + +- remote_control_can_be_started +- remote_control_can_be_stopped +- remote_control_can_be_switched_on +- remote_control_can_be_switched_off +- operation_state +- operation_state_raw +- power_state_on_off +- error_state +- info_state +- temperature_target +- temperature_current +- top_temperature_target +- top_temperature_current +- middle_temperature_target +- middle_temperature_current +- bottom_temperature_target +- bottom_temperature_current + +### Note on plate_power_step channels + +Hob things have an additional property `plateCount` that indicates the number of plates present on the appliance. +Only the channels `plate_1_power_step` to `plate_x_power_step` will be populated by the binding where `x` is the value of the `plateCount` property. + +The plate numbers do not represent the physical layout of the plates on the appliance, but always start with the `plate_1_power_step` channel. +This means that a hob with two plates will have `plate_1_power_step` and `plate_2_power_step` populated and all other `plate_x_power_step` channels empty. + +The `plate_x_power_step` channels show the current power step of the according plate. +**Please note that some hobs may use dynamic numbering for plates.** +Hobs that use dynamic numbering will use the first power step channel that is currently at a power step of zero when the plate is turned on. +Additionally, when a plate is turned off all other plates with higher numbers will decrease their number by one. +For example if plate 1, 2 and 3 are active and plate 1 is turned off then plate 2 will become plate 1, plate 3 will become plate 2 and plate 3 will have a power step of zero. +This behavior is a fixed part of the affected appliances and cannot be changed. + +### Note on door_state channel + +The `door_state` channel might not always provide a value matching the actual state. +For example, a washing machine will not provide a valid `door_state` when the appliance is turned off. +A valid door state can be expected when the appliance is in one of the following raw operation states, compare the `operation_state_raw` channel: + +- `3`: Program selected +- `4`: Program selected, waiting to start +- `5`: Running +- `6`: Paused + +## Properties + +The following chapters list the properties offered by appliances. + +### Common Properties + +| Property Name | Description | +| ------------- | ----------------------------------------------------------------------------- | +| serialNumber | Serial number of the appliance, only present for physical appliances | +| modelId | Model ID of the appliance | +| vendor | Always "Miele" | + +### Account + +| Property Name | Description | +| ------------- | ----------------------------------------------------------------------------- | +| connection | Type of connection used by the account, always "INTERNET" | +| accessToken | The currently used OAuth 2 access token for accessing the Miele 3rd Party API | + +### Hob + +| Property Name | Description | +| ------------- | ----------------------------------------------------------------------------- | +| plateCount | Number of plates offered by the appliance | + +## Full Example + +### demo.things: + +``` +Bridge mielecloud:account:home [ email="me@openhab.org", locale="en" ] { + Thing coffee_system 000703261234 "Coffee machine CVA7440" [ deviceIdentifier="000703261234" ] + Thing hob 000160102345 "Cooktop KM7677" [ deviceIdentifier="000160102345" ] +} +``` + +### demo.items: + +``` +// Coffee system +Switch coffee_system_remote_control_can_be_started { channel="mielecloud:coffee_system:home:000703261234:remote_control_can_be_started" } +Switch coffee_system_remote_control_can_be_stopped { channel="mielecloud:coffee_system:home:000703261234:remote_control_can_be_stopped" } +Switch coffee_system_remote_control_can_be_switched_on { channel="mielecloud:coffee_system:home:000703261234:remote_control_can_be_switched_on" } +Switch coffee_system_remote_control_can_be_switched_off { channel="mielecloud:coffee_system:home:000703261234:remote_control_can_be_switched_off" } +String coffee_system_program_active { channel="mielecloud:coffee_system:home:000703261234:program_active" } +String coffee_system_program_phase { channel="mielecloud:coffee_system:home:000703261234:program_phase" } +String coffee_system_power_state_on_off { channel="mielecloud:coffee_system:home:000703261234:power_state_on_off" } +String coffee_system_operation_state { channel="mielecloud:coffee_system:home:000703261234:operation_state" } +Switch coffee_system_finish_state { channel="mielecloud:coffee_system:home:000703261234:finish_state" } +Number coffee_system_program_remaining_time { channel="mielecloud:coffee_system:home:000703261234:program_remaining_time" } +Switch coffee_system_error_state { channel="mielecloud:coffee_system:home:000703261234:error_state" } +Switch coffee_system_info_state { channel="mielecloud:coffee_system:home:000703261234:info_state" } +Switch coffee_system_light_switch { channel="mielecloud:coffee_system:home:000703261234:light_switch" } +Switch coffee_system_light_can_be_controlled { channel="mielecloud:coffee_system:home:000703261234:light_can_be_controlled" } + +// Hob +Switch hob_remote_control_can_be_started { channel="mielecloud:hob:home:000160102345:remote_control_can_be_started" } +Switch hob_remote_control_can_be_stopped { channel="mielecloud:hob:home:000160102345:remote_control_can_be_stopped" } +String hob_operation_state { channel="mielecloud:hob:home:000160102345:operation_state" } +Switch hob_error_state { channel="mielecloud:hob:home:000160102345:error_state" } +Switch hob_info_state { channel="mielecloud:hob:home:000160102345:info_state" } +Switch hob_plate_1_is_present { channel="mielecloud:hob:home:000160102345:plate_1_is_present" } +String hob_plate_1_power_step { channel="mielecloud:hob:home:000160102345:plate_1_power_step" } +Switch hob_plate_2_is_present { channel="mielecloud:hob:home:000160102345:plate_2_is_present" } +String hob_plate_2_power_step { channel="mielecloud:hob:home:000160102345:plate_2_power_step" } +Switch hob_plate_3_is_present { channel="mielecloud:hob:home:000160102345:plate_3_is_present" } +String hob_plate_3_power_step { channel="mielecloud:hob:home:000160102345:plate_3_power_step" } +Switch hob_plate_4_is_present { channel="mielecloud:hob:home:000160102345:plate_4_is_present" } +String hob_plate_4_power_step { channel="mielecloud:hob:home:000160102345:plate_4_power_step" } +Switch hob_plate_5_is_present { channel="mielecloud:hob:home:000160102345:plate_5_is_present" } +String hob_plate_5_power_step { channel="mielecloud:hob:home:000160102345:plate_5_power_step" } +Switch hob_plate_6_is_present { channel="mielecloud:hob:home:000160102345:plate_6_is_present" } +String hob_plate_6_power_step { channel="mielecloud:hob:home:000160102345:plate_6_power_step" } +``` + +### demo.sitemap: + +``` +sitemap demo label="Kitchen" +{ + Frame { + // Coffee system + Text item=coffee_system_program_active + Text item=coffee_system_program_phase + Text item=coffee_system_power_state_on_off + Text item=coffee_system_operation_state + Switch item=coffee_system_finish_state + Default item=coffee_system_program_remaining_time + Switch item=coffee_system_error_state + Switch item=coffee_system_info_state + Switch item=coffee_system_light_switch + + // Hob + Text item=hob_operation_state + Switch item=hob_error_state + Switch item=hob_info_state + Text item=hob_plate_1_power_step + Text item=hob_plate_2_power_step + Text item=hob_plate_3_power_step + Text item=hob_plate_4_power_step + Text item=hob_plate_5_power_step + Text item=hob_plate_6_power_step + } +} +``` + +## Account Configuration Example + +The configuration UI is accessible at `https:///mielecloud`. +See [Discovery](#discovery) for a detailed description of how to open the configuration UI in a browser. + +When first opening the configuration UI no account will be paired. + +![Empty Account Overview](doc/account-overview-empty.png) + +We strongly recommend to use a secure connection for pairing, details on this topic can also be found in the [Discovery](#discovery) section. +Click `Pair Account` to start the pairing process. +If not already done, go to the [Miele Developer Portal](https://www.miele.com/f/com/en/register_api.aspx), register there and wait for the confirmation e-mail. +Obtain your client ID and client secret according to the instructions presented there. +Once you obtained your client ID and client secret continue pairing by filling in your client ID, client secret, bridge ID and an e-mail address that you wish to use for identifying the account. +You may choose any bridge ID you like as long as you only use letters, numbers, underscores and dashes. +The e-mail address does not need to match the e-mail address used for your Miele Cloud Account. +If you need to change the e-mail address later then you will need to authorize the account again. + +![Pair Account](doc/pair-account.png) + +A click on `Pair Account` will take you to the Miele cloud service login form where you need to log in with the same account as you used for the Miele@mobile app. + +![Miele Login Form](doc/miele-login.png) + +When this is the first time you pair an account, you will need to allow openHAB to access your account. + +When everything worked, you are presented with a page stating that pairing was successful. +Select the locale which should be used to display localized texts in openHAB channels. +From here, you have two options: +Either let the binding automatically configure a bridge instance or copy the presented things-file template to a things-file and return to the overview page. + +![Pairing Successful](doc/pairing-success.png) + +Once the bridge instance is `ONLINE`, you can either pair things for all appliances via your favorite management UI or use a things-file. +The account overview provides a things-file template that is shown when you expand the account. +This can serve as a starting point for your own things-file. + +![Account Overview With Bridge](doc/account-overview-with-bridge.png) + +## Rule Ideas + +Here are some ideas on what could be done with this binding. You have more ideas or even an example? Great! Feel free to contribute! + +- Notify yourself of a finished dishwasher, tumble dryer, washer dryer or washing machine, e.g. by changing the lighting +- Control the supercooler / superfreezer of your freezer, fridge or fridge-freezer combination with a voice assistant +- Notify yourself when the oven has finished pre-heating + +## Acknowledgements + +The development of this binding was initiated and sponsored by Miele & Cie. KG. + diff --git a/bundles/org.openhab.binding.mielecloud/doc/account-overview-empty.png b/bundles/org.openhab.binding.mielecloud/doc/account-overview-empty.png new file mode 100644 index 0000000000000..7b733af0be8bc Binary files /dev/null and b/bundles/org.openhab.binding.mielecloud/doc/account-overview-empty.png differ diff --git a/bundles/org.openhab.binding.mielecloud/doc/account-overview-with-bridge.png b/bundles/org.openhab.binding.mielecloud/doc/account-overview-with-bridge.png new file mode 100644 index 0000000000000..2a9361b595f94 Binary files /dev/null and b/bundles/org.openhab.binding.mielecloud/doc/account-overview-with-bridge.png differ diff --git a/bundles/org.openhab.binding.mielecloud/doc/miele-login.png b/bundles/org.openhab.binding.mielecloud/doc/miele-login.png new file mode 100644 index 0000000000000..f6a14dbd9999d Binary files /dev/null and b/bundles/org.openhab.binding.mielecloud/doc/miele-login.png differ diff --git a/bundles/org.openhab.binding.mielecloud/doc/pair-account.png b/bundles/org.openhab.binding.mielecloud/doc/pair-account.png new file mode 100644 index 0000000000000..54932216e1539 Binary files /dev/null and b/bundles/org.openhab.binding.mielecloud/doc/pair-account.png differ diff --git a/bundles/org.openhab.binding.mielecloud/doc/pairing-success.png b/bundles/org.openhab.binding.mielecloud/doc/pairing-success.png new file mode 100644 index 0000000000000..f10bf6284bdc9 Binary files /dev/null and b/bundles/org.openhab.binding.mielecloud/doc/pairing-success.png differ diff --git a/bundles/org.openhab.binding.mielecloud/pom.xml b/bundles/org.openhab.binding.mielecloud/pom.xml new file mode 100644 index 0000000000000..2e6d1f621db9a --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.1.0-SNAPSHOT + + + org.openhab.binding.mielecloud + + openHAB Add-ons :: Bundles :: Miele Cloud Binding + + diff --git a/bundles/org.openhab.binding.mielecloud/src/main/feature/feature.xml b/bundles/org.openhab.binding.mielecloud/src/main/feature/feature.xml new file mode 100644 index 0000000000000..4024caa71eca5 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/feature/feature.xml @@ -0,0 +1,23 @@ + + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.mielecloud/${project.version} + + diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/MieleCloudBindingConstants.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/MieleCloudBindingConstants.java new file mode 100644 index 0000000000000..59b1426ad5a51 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/MieleCloudBindingConstants.java @@ -0,0 +1,238 @@ +/** + * 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.mielecloud.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link MieleCloudBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Roland Edelhoff - Initial contribution + * @author Björn Lange - Added locale config parameter, added i18n key collection + * @author Benjamin Bolte - Add pre-heat finished and plate step channels, door state and door alarm channels, info + * state channel and map signal flags from API + * @author Björn Lange - Add elapsed time channel, dish warmer thing + */ +@NonNullByDefault +public final class MieleCloudBindingConstants { + + private MieleCloudBindingConstants() { + } + + /** + * ID of the binding. + */ + public static final String BINDING_ID = "mielecloud"; + + /** + * Thing type ID of Miele cloud bridges / accounts. + */ + public static final String BRIDGE_TYPE_ID = "account"; + + /** + * The {@link ThingTypeUID} of Miele cloud bridges / accounts. + */ + public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, BRIDGE_TYPE_ID); + + /** + * The {@link ThingTypeUID} of Miele washing machines. + */ + public static final ThingTypeUID THING_TYPE_WASHING_MACHINE = new ThingTypeUID(BINDING_ID, "washing_machine"); + + /** + * The {@link ThingTypeUID} of Miele washer-dryers. + */ + public static final ThingTypeUID THING_TYPE_WASHER_DRYER = new ThingTypeUID(BINDING_ID, "washer_dryer"); + + /** + * The {@link ThingTypeUID} of Miele coffee machines. + */ + public static final ThingTypeUID THING_TYPE_COFFEE_SYSTEM = new ThingTypeUID(BINDING_ID, "coffee_system"); + + /** + * The {@link ThingTypeUID} of Miele fridge-freezers. + */ + public static final ThingTypeUID THING_TYPE_FRIDGE_FREEZER = new ThingTypeUID(BINDING_ID, "fridge_freezer"); + + /** + * The {@link ThingTypeUID} of Miele fridges. + */ + public static final ThingTypeUID THING_TYPE_FRIDGE = new ThingTypeUID(BINDING_ID, "fridge"); + + /** + * The {@link ThingTypeUID} of Miele freezers. + */ + public static final ThingTypeUID THING_TYPE_FREEZER = new ThingTypeUID(BINDING_ID, "freezer"); + + /** + * The {@link ThingTypeUID} of Miele ovens. + */ + public static final ThingTypeUID THING_TYPE_OVEN = new ThingTypeUID(BINDING_ID, "oven"); + + /** + * The {@link ThingTypeUID} of Miele hobs. + */ + public static final ThingTypeUID THING_TYPE_HOB = new ThingTypeUID(BINDING_ID, "hob"); + + /** + * The {@link ThingTypeUID} of Miele wine storages. + */ + public static final ThingTypeUID THING_TYPE_WINE_STORAGE = new ThingTypeUID(BINDING_ID, "wine_storage"); + + /** + * The {@link ThingTypeUID} of Miele dishwashers. + */ + public static final ThingTypeUID THING_TYPE_DISHWASHER = new ThingTypeUID(BINDING_ID, "dishwasher"); + + /** + * The {@link ThingTypeUID} of Miele dryers. + */ + public static final ThingTypeUID THING_TYPE_DRYER = new ThingTypeUID(BINDING_ID, "dryer"); + + /** + * The {@link ThingTypeUID} of Miele hoods. + */ + public static final ThingTypeUID THING_TYPE_HOOD = new ThingTypeUID(BINDING_ID, "hood"); + + /** + * The {@link ThingTypeUID} of Miele dish warmers. + */ + public static final ThingTypeUID THING_TYPE_DISH_WARMER = new ThingTypeUID(BINDING_ID, "dish_warmer"); + + /** + * The {@link ThingTypeUID} of Miele robotic vacuum cleaners. + */ + public static final ThingTypeUID THING_TYPE_ROBOTIC_VACUUM_CLEANER = new ThingTypeUID(BINDING_ID, + "robotic_vacuum_cleaner"); + + /** + * Name of the property storing the OAuth2 access token. + */ + public static final String PROPERTY_ACCESS_TOKEN = "accessToken"; + + /** + * Name of the configuration parameter for the e-mail address. + */ + public static final String CONFIG_PARAM_EMAIL = "email"; + + /** + * Name of the configuration parameter for the device identifier uniquely identifying a Miele device. + */ + public static final String CONFIG_PARAM_DEVICE_IDENTIFIER = "deviceIdentifier"; + + /** + * Name of the configuration parameter for the locale. The locale is stored as a 2-letter language code. + */ + public static final String CONFIG_PARAM_LOCALE = "locale"; + + /** + * Name of the property storing the number of plates for hobs. + */ + public static final String PROPERTY_PLATE_COUNT = "plateCount"; + + /** + * Constants for all channels. + */ + public static final class Channels { + private Channels() { + } + + public static final String REMOTE_CONTROL_CAN_BE_STARTED = "remote_control_can_be_started"; + public static final String REMOTE_CONTROL_CAN_BE_STOPPED = "remote_control_can_be_stopped"; + public static final String REMOTE_CONTROL_CAN_BE_PAUSED = "remote_control_can_be_paused"; + public static final String REMOTE_CONTROL_CAN_BE_SWITCHED_ON = "remote_control_can_be_switched_on"; + public static final String REMOTE_CONTROL_CAN_BE_SWITCHED_OFF = "remote_control_can_be_switched_off"; + public static final String REMOTE_CONTROL_CAN_SET_PROGRAM_ACTIVE = "remote_control_can_set_program_active"; + public static final String SPINNING_SPEED = "spinning_speed"; + public static final String SPINNING_SPEED_RAW = "spinning_speed_raw"; + public static final String PROGRAM_ACTIVE = "program_active"; + public static final String PROGRAM_ACTIVE_RAW = "program_active_raw"; + public static final String DISH_WARMER_PROGRAM_ACTIVE = "dish_warmer_program_active"; + public static final String VACUUM_CLEANER_PROGRAM_ACTIVE = "vacuum_cleaner_program_active"; + public static final String PROGRAM_PHASE = "program_phase"; + public static final String PROGRAM_PHASE_RAW = "program_phase_raw"; + public static final String OPERATION_STATE = "operation_state"; + public static final String OPERATION_STATE_RAW = "operation_state_raw"; + public static final String PROGRAM_START_STOP = "program_start_stop"; + public static final String PROGRAM_START_STOP_PAUSE = "program_start_stop_pause"; + public static final String POWER_ON_OFF = "power_state_on_off"; + public static final String FINISH_STATE = "finish_state"; + public static final String DELAYED_START_TIME = "delayed_start_time"; + public static final String PROGRAM_REMAINING_TIME = "program_remaining_time"; + public static final String PROGRAM_ELAPSED_TIME = "program_elapsed_time"; + public static final String PROGRAM_PROGRESS = "program_progress"; + public static final String DRYING_TARGET = "drying_target"; + public static final String DRYING_TARGET_RAW = "drying_target_raw"; + public static final String PRE_HEAT_FINISHED = "pre_heat_finished"; + public static final String TEMPERATURE_TARGET = "temperature_target"; + public static final String TEMPERATURE_CURRENT = "temperature_current"; + public static final String TEMPERATURE_CORE_TARGET = "temperature_core_target"; + public static final String TEMPERATURE_CORE_CURRENT = "temperature_core_current"; + public static final String VENTILATION_POWER = "ventilation_power"; + public static final String VENTILATION_POWER_RAW = "ventilation_power_raw"; + public static final String ERROR_STATE = "error_state"; + public static final String INFO_STATE = "info_state"; + public static final String FRIDGE_SUPER_COOL = "fridge_super_cool"; + public static final String FREEZER_SUPER_FREEZE = "freezer_super_freeze"; + public static final String SUPER_COOL_CAN_BE_CONTROLLED = "super_cool_can_be_controlled"; + public static final String SUPER_FREEZE_CAN_BE_CONTROLLED = "super_freeze_can_be_controlled"; + public static final String FRIDGE_TEMPERATURE_TARGET = "fridge_temperature_target"; + public static final String FRIDGE_TEMPERATURE_CURRENT = "fridge_temperature_current"; + public static final String FREEZER_TEMPERATURE_TARGET = "freezer_temperature_target"; + public static final String FREEZER_TEMPERATURE_CURRENT = "freezer_temperature_current"; + public static final String TOP_TEMPERATURE_TARGET = "top_temperature_target"; + public static final String TOP_TEMPERATURE_CURRENT = "top_temperature_current"; + public static final String MIDDLE_TEMPERATURE_TARGET = "middle_temperature_target"; + public static final String MIDDLE_TEMPERATURE_CURRENT = "middle_temperature_current"; + public static final String BOTTOM_TEMPERATURE_TARGET = "bottom_temperature_target"; + public static final String BOTTOM_TEMPERATURE_CURRENT = "bottom_temperature_current"; + public static final String LIGHT_SWITCH = "light_switch"; + public static final String LIGHT_CAN_BE_CONTROLLED = "light_can_be_controlled"; + public static final String PLATE_1_POWER_STEP = "plate_1_power_step"; + public static final String PLATE_1_POWER_STEP_RAW = "plate_1_power_step_raw"; + public static final String PLATE_2_POWER_STEP = "plate_2_power_step"; + public static final String PLATE_2_POWER_STEP_RAW = "plate_2_power_step_raw"; + public static final String PLATE_3_POWER_STEP = "plate_3_power_step"; + public static final String PLATE_3_POWER_STEP_RAW = "plate_3_power_step_raw"; + public static final String PLATE_4_POWER_STEP = "plate_4_power_step"; + public static final String PLATE_4_POWER_STEP_RAW = "plate_4_power_step_raw"; + public static final String PLATE_5_POWER_STEP = "plate_5_power_step"; + public static final String PLATE_5_POWER_STEP_RAW = "plate_5_power_step_raw"; + public static final String PLATE_6_POWER_STEP = "plate_6_power_step"; + public static final String PLATE_6_POWER_STEP_RAW = "plate_6_power_step_raw"; + public static final String DOOR_STATE = "door_state"; + public static final String DOOR_ALARM = "door_alarm"; + public static final String BATTERY_LEVEL = "battery_level"; + } + + /** + * Constants for i18n keys. + */ + public static final class I18NKeys { + private I18NKeys() { + } + + public static final String BRIDGE_STATUS_DESCRIPTION_ACCESS_TOKEN_NOT_CONFIGURED = "@text/mielecloud.bridge.status.access.token.not.configured"; + public static final String BRIDGE_STATUS_DESCRIPTION_ACCOUNT_NOT_AUTHORIZED = "@text/mielecloud.bridge.status.account.not.authorized"; + public static final String BRIDGE_STATUS_DESCRIPTION_ACCESS_TOKEN_REFRESH_FAILED = "@text/mielecloud.bridge.status.access.token.refresh.failed"; + public static final String BRIDGE_STATUS_DESCRIPTION_INVALID_EMAIL = "@text/mielecloud.bridge.status.invalid.email"; + public static final String BRIDGE_STATUS_DESCRIPTION_TRANSIENT_HTTP_ERROR = "@text/mielecloud.bridge.status.transient.http.error"; + + public static final String THING_STATUS_DESCRIPTION_WEBSERVICE_MISSING = "@text/mielecloud.thing.status.webservice.missing"; + public static final String THING_STATUS_DESCRIPTION_REMOVED = "@text/mielecloud.thing.status.removed"; + public static final String THING_STATUS_DESCRIPTION_RATELIMIT = "@text/mielecloud.thing.status.ratelimit"; + public static final String THING_STATUS_DESCRIPTION_DISCONNECTED = "@text/mielecloud.thing.status.disconnected"; + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthException.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthException.java new file mode 100644 index 0000000000000..e607db6d2324b --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthException.java @@ -0,0 +1,33 @@ +/** + * 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.mielecloud.internal.auth; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Indicates an error in the OAuth2 authorization process. + * + * @author Roland Edelhoff - Initial contribution + */ +@NonNullByDefault +public class OAuthException extends RuntimeException { + private static final long serialVersionUID = -1863609233382694104L; + + public OAuthException(final String message) { + super(message); + } + + public OAuthException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthTokenRefreshListener.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthTokenRefreshListener.java new file mode 100644 index 0000000000000..94b723bdbe955 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthTokenRefreshListener.java @@ -0,0 +1,30 @@ +/** + * 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.mielecloud.internal.auth; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Listener that is invoked when an OAuth 2 access token was refreshed. + * + * @author Björn Lange - Initial contribution + */ +@NonNullByDefault +public interface OAuthTokenRefreshListener { + /** + * Invoked when a new access token becomes available. + * + * @param accessToken The new access token. + */ + public void onNewAccessToken(String accessToken); +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthTokenRefresher.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthTokenRefresher.java new file mode 100644 index 0000000000000..c5cbd3f35f46b --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OAuthTokenRefresher.java @@ -0,0 +1,74 @@ +/** + * 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.mielecloud.internal.auth; + +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * An {@link OAuthTokenRefresher} offers convenient access to OAuth 2 authentication related functionality, + * especially refreshing the access token. + * + * @author Roland Edelhoff - Initial contribution + * @author Björn Lange - Allow removing tokens from the storage + */ +@NonNullByDefault +public interface OAuthTokenRefresher { + /** + * Sets the listener that is called when the access token was refreshed. + * + * @param listener The listener to register. + * @param serviceHandle The service handle identifying the internal OAuth configuration. + * @throws OAuthException if the listener needs to be registered at an underlying service which is not available + * because the account has not yet been authorized + */ + public void setRefreshListener(OAuthTokenRefreshListener listener, String serviceHandle); + + /** + * Unsets a listener. + * + * @param serviceHandle The service handle identifying the internal OAuth configuration. + */ + public void unsetRefreshListener(String serviceHandle); + + /** + * Refreshes the access and refresh tokens for the given service handle. If an {@link OAuthTokenRefreshListener} is + * registered for the service handle then it is notified after the refresh has completed. + * + * This call will succeed if the access token is still valid or a valid refresh token exists, which can be used to + * refresh the expired access token. If refreshing fails, an {@link OAuthException} is thrown. + * + * @param serviceHandle The service handle identifying the internal OAuth configuration. + * @throws OAuthException if the token cannot be obtained or refreshed + */ + public void refreshToken(String serviceHandle); + + /** + * Gets the currently stored access token from persistent storage. + * + * @param serviceHandle The service handle identifying the internal OAuth configuration. + * @return The currently stored access token or an empty {@link Optional} if there is no stored token. + */ + public Optional getAccessTokenFromStorage(String serviceHandle); + + /** + * Removes the tokens from persistent storage. + * + * Note: Calling this method will force the user to run through the pairing process again in order to obtain a + * working bridge. + * + * @param serviceHandle The service handle identifying the internal OAuth configuration. + */ + public void removeTokensFromStorage(String serviceHandle); +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OpenHabOAuthTokenRefresher.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OpenHabOAuthTokenRefresher.java new file mode 100644 index 0000000000000..a947da391fac7 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/auth/OpenHabOAuthTokenRefresher.java @@ -0,0 +1,138 @@ +/** + * 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.mielecloud.internal.auth; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles refreshing of OAuth2 tokens managed by the openHAB runtime. + * + * @author Björn Lange - Initial contribution + */ +@Component +@NonNullByDefault +public final class OpenHabOAuthTokenRefresher implements OAuthTokenRefresher { + private final Logger logger = LoggerFactory.getLogger(OpenHabOAuthTokenRefresher.class); + + private final OAuthFactory oauthFactory; + private Map listenerByServiceHandle = new HashMap<>(); + + @Activate + public OpenHabOAuthTokenRefresher(@Reference OAuthFactory oauthFactory) { + this.oauthFactory = oauthFactory; + } + + @Override + public void setRefreshListener(OAuthTokenRefreshListener listener, String serviceHandle) { + final AccessTokenRefreshListener refreshListener = tokenResponse -> { + final String accessToken = tokenResponse.getAccessToken(); + if (accessToken == null) { + // Fail without exception to ensure that the OAuthClientService notifies all listeners. + logger.warn("Ignoring access token response without access token."); + } else { + listener.onNewAccessToken(accessToken); + } + }; + + OAuthClientService clientService = getOAuthClientService(serviceHandle); + clientService.addAccessTokenRefreshListener(refreshListener); + listenerByServiceHandle.put(serviceHandle, refreshListener); + } + + @Override + public void unsetRefreshListener(String serviceHandle) { + final AccessTokenRefreshListener refreshListener = listenerByServiceHandle.get(serviceHandle); + if (refreshListener != null) { + try { + OAuthClientService clientService = getOAuthClientService(serviceHandle); + clientService.removeAccessTokenRefreshListener(refreshListener); + } catch (OAuthException e) { + logger.warn("Failed to remove refresh listener: OAuth client service is unavailable. Cause: {}", + e.getMessage()); + } + } + listenerByServiceHandle.remove(serviceHandle); + } + + @Override + public void refreshToken(String serviceHandle) { + if (listenerByServiceHandle.get(serviceHandle) == null) { + logger.warn("Token refreshing was requested but there is no token refresh listener registered!"); + return; + } + + OAuthClientService clientService = getOAuthClientService(serviceHandle); + refreshAccessToken(clientService); + } + + private OAuthClientService getOAuthClientService(String serviceHandle) { + final OAuthClientService clientService = oauthFactory.getOAuthClientService(serviceHandle); + if (clientService == null) { + throw new OAuthException("OAuth client service is not available."); + } + return clientService; + } + + private void refreshAccessToken(OAuthClientService clientService) { + try { + final AccessTokenResponse accessTokenResponse = clientService.refreshToken(); + final String accessToken = accessTokenResponse.getAccessToken(); + if (accessToken == null) { + throw new OAuthException("Access token is not available."); + } + } catch (org.openhab.core.auth.client.oauth2.OAuthException e) { + throw new OAuthException("An error occured during token refresh: " + e.getMessage(), e); + } catch (IOException e) { + throw new OAuthException("A network error occured during token refresh: " + e.getMessage(), e); + } catch (OAuthResponseException e) { + throw new OAuthException("Miele cloud service returned an illegal response: " + e.getMessage(), e); + } + } + + @Override + public Optional getAccessTokenFromStorage(String serviceHandle) { + try { + AccessTokenResponse tokenResponse = getOAuthClientService(serviceHandle).getAccessTokenResponse(); + if (tokenResponse == null) { + return Optional.empty(); + } else { + return Optional.of(tokenResponse.getAccessToken()); + } + } catch (OAuthException | org.openhab.core.auth.client.oauth2.OAuthException | IOException + | OAuthResponseException e) { + logger.debug("Cannot obtain access token from persistent storage.", e); + return Optional.empty(); + } + } + + @Override + public void removeTokensFromStorage(String serviceHandle) { + oauthFactory.deleteServiceAndAccessToken(serviceHandle); + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/MieleCloudConfigService.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/MieleCloudConfigService.java new file mode 100644 index 0000000000000..a2b15cc093fd9 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/MieleCloudConfigService.java @@ -0,0 +1,222 @@ +/** + * 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.mielecloud.internal.config; + +import java.util.Hashtable; +import java.util.Map; + +import javax.servlet.ServletException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mielecloud.internal.config.servlet.AccountOverviewServlet; +import org.openhab.binding.mielecloud.internal.config.servlet.CreateBridgeServlet; +import org.openhab.binding.mielecloud.internal.config.servlet.FailureServlet; +import org.openhab.binding.mielecloud.internal.config.servlet.ForwardToLoginServlet; +import org.openhab.binding.mielecloud.internal.config.servlet.PairAccountServlet; +import org.openhab.binding.mielecloud.internal.config.servlet.ResourceLoader; +import org.openhab.binding.mielecloud.internal.config.servlet.ResultServlet; +import org.openhab.binding.mielecloud.internal.config.servlet.SuccessServlet; +import org.openhab.binding.mielecloud.internal.webservice.language.CombiningLanguageProvider; +import org.openhab.binding.mielecloud.internal.webservice.language.JvmLanguageProvider; +import org.openhab.binding.mielecloud.internal.webservice.language.LanguageProvider; +import org.openhab.binding.mielecloud.internal.webservice.language.OpenHabLanguageProvider; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.config.discovery.inbox.Inbox; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles the lifecycle of the Miele Cloud binding's configuration UI. + * + * @author Björn Lange - Initial Contribution + */ +@Component(service = MieleCloudConfigService.class, immediate = true, configurationPid = "binding.mielecloud.configService") +@NonNullByDefault +public final class MieleCloudConfigService { + private static final String ROOT_ALIAS = "/mielecloud"; + private static final String PAIR_ALIAS = ROOT_ALIAS + "/pair"; + private static final String FORWARD_TO_LOGIN_ALIAS = ROOT_ALIAS + "/forwardToLogin"; + private static final String RESULT_ALIAS = ROOT_ALIAS + "/result"; + private static final String SUCCESS_ALIAS = ROOT_ALIAS + "/success"; + private static final String CREATE_BRIDGE_THING_ALIAS = ROOT_ALIAS + "/createBridgeThing"; + private static final String FAILURE_ALIAS = ROOT_ALIAS + "/failure"; + private static final String CSS_ALIAS = ROOT_ALIAS + "/assets/css"; + private static final String JS_ALIAS = ROOT_ALIAS + "/assets/js"; + private static final String IMG_ALIAS = ROOT_ALIAS + "/assets/img"; + + private static final String WEBSITE_RESOURCE_BASE_PATH = "org/openhab/binding/mielecloud/internal/config"; + private static final String WEBSITE_CSS_RESOURCE_PATH = WEBSITE_RESOURCE_BASE_PATH + "/assets/css"; + private static final String WEBSITE_JS_RESOURCE_PATH = WEBSITE_RESOURCE_BASE_PATH + "/assets/js"; + private static final String WEBSITE_IMG_RESOURCE_PATH = WEBSITE_RESOURCE_BASE_PATH + "/assets/img"; + + private final Logger logger = LoggerFactory.getLogger(MieleCloudConfigService.class); + + private HttpService httpService; + private OAuthFactory oauthFactory; + private Inbox inbox; + private ThingRegistry thingRegistry; + private LocaleProvider localeProvider; + + /** + * For integration test purposes only. + */ + @Nullable + private AccountOverviewServlet accountOverviewServlet; + + /** + * For integration test purposes only. + */ + @Nullable + private ForwardToLoginServlet forwardToLoginServlet; + + /** + * For integration test purposes only. + */ + @Nullable + private ResultServlet resultServlet; + + /** + * For integration test purposes only. + */ + @Nullable + private SuccessServlet successServlet; + + /** + * For integration test purposes only. + */ + @Nullable + private CreateBridgeServlet createBridgeServlet; + + @Activate + public MieleCloudConfigService(@Reference HttpService httpService, @Reference OAuthFactory oauthFactory, + @Reference Inbox inbox, @Reference ThingRegistry thingRegistry, @Reference LocaleProvider localeProvider) { + this.httpService = httpService; + this.oauthFactory = oauthFactory; + this.inbox = inbox; + this.thingRegistry = thingRegistry; + this.localeProvider = localeProvider; + } + + @Nullable + public AccountOverviewServlet getAccountOverviewServlet() { + return accountOverviewServlet; + } + + @Nullable + public ForwardToLoginServlet getForwardToLoginServlet() { + return forwardToLoginServlet; + } + + @Nullable + public ResultServlet getResultServlet() { + return resultServlet; + } + + @Nullable + public SuccessServlet getSuccessServlet() { + return successServlet; + } + + @Nullable + public CreateBridgeServlet getCreateBridgeServlet() { + return createBridgeServlet; + } + + @Activate + protected void activate(ComponentContext componentContext, Map properties) { + registerWebsite(componentContext.getBundleContext()); + } + + private void registerWebsite(BundleContext bundleContext) { + ResourceLoader resourceLoader = new ResourceLoader(WEBSITE_RESOURCE_BASE_PATH, bundleContext); + OAuthAuthorizationHandler authorizationHandler = new OAuthAuthorizationHandlerImpl(oauthFactory, + ThreadPoolManager.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON)); + + try { + HttpContext httpContext = httpService.createDefaultHttpContext(); + httpService.registerServlet(ROOT_ALIAS, + accountOverviewServlet = new AccountOverviewServlet(resourceLoader, thingRegistry, inbox), + new Hashtable<>(), httpContext); + httpService.registerServlet(PAIR_ALIAS, new PairAccountServlet(resourceLoader), new Hashtable<>(), + httpContext); + httpService.registerServlet(FORWARD_TO_LOGIN_ALIAS, + forwardToLoginServlet = new ForwardToLoginServlet(authorizationHandler), new Hashtable<>(), + httpContext); + httpService.registerServlet(RESULT_ALIAS, resultServlet = new ResultServlet(authorizationHandler), + new Hashtable<>(), httpContext); + httpService.registerServlet(SUCCESS_ALIAS, + successServlet = new SuccessServlet(resourceLoader, createLanguageProvider()), new Hashtable<>(), + httpContext); + httpService.registerServlet(CREATE_BRIDGE_THING_ALIAS, + createBridgeServlet = new CreateBridgeServlet(inbox, thingRegistry), new Hashtable<>(), + httpContext); + httpService.registerServlet(FAILURE_ALIAS, new FailureServlet(resourceLoader), new Hashtable<>(), + httpContext); + httpService.registerResources(CSS_ALIAS, WEBSITE_CSS_RESOURCE_PATH, httpContext); + httpService.registerResources(JS_ALIAS, WEBSITE_JS_RESOURCE_PATH, httpContext); + httpService.registerResources(IMG_ALIAS, WEBSITE_IMG_RESOURCE_PATH, httpContext); + logger.debug("Registered Miele Cloud binding website at /mielecloud"); + } catch (NamespaceException | ServletException e) { + logger.warn( + "Failed to register Miele Cloud binding website. Miele Cloud binding website will not be available.", + e); + unregisterWebsite(); + } + } + + private LanguageProvider createLanguageProvider() { + return new CombiningLanguageProvider(new OpenHabLanguageProvider(localeProvider), new JvmLanguageProvider()); + } + + @Deactivate + protected void deactivate() { + unregisterWebsite(); + } + + private void unregisterWebsite() { + unregisterWebResource(ROOT_ALIAS); + unregisterWebResource(PAIR_ALIAS); + unregisterWebResource(FORWARD_TO_LOGIN_ALIAS); + unregisterWebResource(RESULT_ALIAS); + unregisterWebResource(SUCCESS_ALIAS); + unregisterWebResource(CREATE_BRIDGE_THING_ALIAS); + unregisterWebResource(CSS_ALIAS); + unregisterWebResource(JS_ALIAS); + unregisterWebResource(IMG_ALIAS); + forwardToLoginServlet = null; + resultServlet = null; + createBridgeServlet = null; + logger.debug("Unregistered Miele Cloud binding website at /mielecloud"); + } + + private void unregisterWebResource(String alias) { + try { + httpService.unregister(alias); + } catch (IllegalArgumentException e) { + logger.warn("Failed to unregister Miele Cloud binding website alias {}", alias, e); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/OAuthAuthorizationHandler.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/OAuthAuthorizationHandler.java new file mode 100644 index 0000000000000..9acf5030a715f --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/OAuthAuthorizationHandler.java @@ -0,0 +1,80 @@ +/** + * 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.mielecloud.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mielecloud.internal.config.exception.NoOngoingAuthorizationException; +import org.openhab.binding.mielecloud.internal.config.exception.OngoingAuthorizationException; +import org.openhab.core.thing.ThingUID; + +/** + * Handles OAuth 2 authorization processes. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public interface OAuthAuthorizationHandler { + /** + * Begins the authorization process after the user provided client ID, client secret and a bridge ID. + * + * @param clientId Client ID. + * @param clientSecret Client secret. + * @param bridgeUid The UID of the bridge to authorize. + * @param email E-mail address identifying the account to authorize. + * @throws OngoingAuthorizationException if there already is an ongoing authorization. + */ + void beginAuthorization(String clientId, String clientSecret, ThingUID bridgeUid, String email); + + /** + * Creates the authorization URL for the ongoing authorization. + * + * @param redirectUri The URI to which the user is redirected after a successful login. This should point to our own + * service. + * @return The authorization URL to which the user is redirected for the log in. + * @throws NoOngoingAuthorizationException if there is no ongoing authorization. + * @throws OAuthException if the authorization URL cannot be determined. In this case the ongoing authorization is + * cancelled. + */ + String getAuthorizationUrl(String redirectUri); + + /** + * Gets the UID of the bridge that is currently being authorized. + */ + ThingUID getBridgeUid(); + + /** + * Gets the e-mail address associated with the account that is currently being authorized. + */ + String getEmail(); + + /** + * Completes the authorization by extracting the authorization code from the given redirection URL, fetching the + * access token response and persisting it. After this method succeeded the access token can be read from the + * persistent storage. + * + * @param redirectUrlWithParameters The URL the remote service redirected the user to. This is the URL our servlet + * was called with. + * @throws NoOngoingAuthorizationException if there is no ongoing authorization. + * @throws OAuthException if the authorization failed. In this case the ongoing authorization is cancelled. + */ + void completeAuthorization(String redirectUrlWithParameters); + + /** + * Gets the access token from persistent storage. + * + * @param email E-mail address for which the access token is requested. + * @return The access token. + * @throws OAuthException if the access token cannot be obtained. + */ + String getAccessToken(String email); +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/OAuthAuthorizationHandlerImpl.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/OAuthAuthorizationHandlerImpl.java new file mode 100644 index 0000000000000..86ef742af1e66 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/OAuthAuthorizationHandlerImpl.java @@ -0,0 +1,213 @@ +/** + * 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.mielecloud.internal.config; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mielecloud.internal.auth.OAuthException; +import org.openhab.binding.mielecloud.internal.config.exception.NoOngoingAuthorizationException; +import org.openhab.binding.mielecloud.internal.config.exception.OngoingAuthorizationException; +import org.openhab.binding.mielecloud.internal.webservice.DefaultMieleWebservice; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.openhab.core.thing.ThingUID; + +/** + * {@link OAuthAuthorizationHandler} implementation handling the OAuth 2 authorization via openHAB services. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class OAuthAuthorizationHandlerImpl implements OAuthAuthorizationHandler { + private static final String TOKEN_URL = DefaultMieleWebservice.THIRD_PARTY_ENDPOINTS_BASENAME + "/token"; + private static final String AUTHORIZATION_URL = DefaultMieleWebservice.THIRD_PARTY_ENDPOINTS_BASENAME + "/login"; + + private static final long AUTHORIZATION_TIMEOUT_IN_MINUTES = 5; + + private final OAuthFactory oauthFactory; + private final ScheduledExecutorService scheduler; + + @Nullable + private OAuthClientService oauthClientService; + @Nullable + private ThingUID bridgeUid; + @Nullable + private String email; + @Nullable + private String redirectUri; + @Nullable + private ScheduledFuture timer; + @Nullable + private LocalDateTime timerExpiryTimestamp; + + /** + * Creates a new {@link OAuthAuthorizationHandlerImpl}. + * + * @param oauthFactory Factory for accessing the {@link OAuthClientService}. + * @param scheduler System-wide scheduler. + */ + public OAuthAuthorizationHandlerImpl(OAuthFactory oauthFactory, ScheduledExecutorService scheduler) { + this.oauthFactory = oauthFactory; + this.scheduler = scheduler; + } + + @Override + public synchronized void beginAuthorization(String clientId, String clientSecret, ThingUID bridgeUid, + String email) { + if (this.oauthClientService != null) { + throw new OngoingAuthorizationException("There is already an ongoing authorization!", timerExpiryTimestamp); + } + + this.oauthClientService = oauthFactory.createOAuthClientService(email, TOKEN_URL, AUTHORIZATION_URL, clientId, + clientSecret, null, false); + this.bridgeUid = bridgeUid; + this.email = email; + redirectUri = null; + timer = null; + timerExpiryTimestamp = null; + } + + @Override + public synchronized String getAuthorizationUrl(String redirectUri) { + final OAuthClientService oauthClientService = this.oauthClientService; + if (oauthClientService == null) { + throw new NoOngoingAuthorizationException("There is no ongoing authorization!"); + } + + this.redirectUri = redirectUri; + try { + timer = scheduler.schedule(this::cancelAuthorization, AUTHORIZATION_TIMEOUT_IN_MINUTES, TimeUnit.MINUTES); + timerExpiryTimestamp = LocalDateTime.now().plusMinutes(AUTHORIZATION_TIMEOUT_IN_MINUTES); + return oauthClientService.getAuthorizationUrl(redirectUri, null, null); + } catch (org.openhab.core.auth.client.oauth2.OAuthException e) { + abortTimer(); + cancelAuthorization(); + throw new OAuthException("Failed to determine authorization URL: " + e.getMessage(), e); + } + } + + @Override + public ThingUID getBridgeUid() { + final ThingUID bridgeUid = this.bridgeUid; + if (bridgeUid == null) { + throw new NoOngoingAuthorizationException("There is no ongoing authorization."); + } + return bridgeUid; + } + + @Override + public String getEmail() { + final String email = this.email; + if (email == null) { + throw new NoOngoingAuthorizationException("There is no ongoing authorization."); + } + return email; + } + + @Override + public synchronized void completeAuthorization(String redirectUrlWithParameters) { + abortTimer(); + + final OAuthClientService oauthClientService = this.oauthClientService; + if (oauthClientService == null) { + throw new NoOngoingAuthorizationException("There is no ongoing authorization."); + } + + try { + String authorizationCode = oauthClientService.extractAuthCodeFromAuthResponse(redirectUrlWithParameters); + + // Although this method is called "get" it actually fetches and stores the token response as a side effect. + oauthClientService.getAccessTokenResponseByAuthorizationCode(authorizationCode, redirectUri); + } catch (IOException e) { + throw new OAuthException("Network error while retrieving token response: " + e.getMessage(), e); + } catch (OAuthResponseException e) { + throw new OAuthException("Failed to retrieve token response: " + e.getMessage(), e); + } catch (org.openhab.core.auth.client.oauth2.OAuthException e) { + throw new OAuthException("Error while processing Miele service response: " + e.getMessage(), e); + } finally { + this.oauthClientService = null; + this.bridgeUid = null; + this.email = null; + this.redirectUri = null; + } + } + + /** + * Aborts the timer. + * + * Note: All calls to this method must be {@code synchronized} to ensure thread-safety. Also note that + * {@link #cancelAuthorization()} is {@code synchronized} so the execution of this method and + * {@link #cancelAuthorization()} cannot overlap. Therefore, this method is an atomic operation from the timer's + * perspective. + */ + private void abortTimer() { + final ScheduledFuture timer = this.timer; + if (timer == null) { + return; + } + + if (!timer.isDone()) { + timer.cancel(false); + } + this.timer = null; + timerExpiryTimestamp = null; + } + + private synchronized void cancelAuthorization() { + oauthClientService = null; + bridgeUid = null; + email = null; + redirectUri = null; + final ScheduledFuture timer = this.timer; + if (timer != null) { + timer.cancel(false); + this.timer = null; + timerExpiryTimestamp = null; + } + } + + @Override + public String getAccessToken(String email) { + OAuthClientService clientService = oauthFactory.getOAuthClientService(email); + if (clientService == null) { + throw new OAuthException("There is no access token registered for '" + email + "'"); + } + + try { + AccessTokenResponse response = clientService.getAccessTokenResponse(); + if (response == null) { + throw new OAuthException( + "There is no access token in the persistent storage or it already expired and could not be refreshed"); + } else { + return response.getAccessToken(); + } + } catch (org.openhab.core.auth.client.oauth2.OAuthException e) { + throw new OAuthException("Failed to read access token from persistent storage: " + e.getMessage(), e); + } catch (IOException e) { + throw new OAuthException( + "Network error during token refresh or error while reading from persistent storage: " + + e.getMessage(), + e); + } catch (OAuthResponseException e) { + throw new OAuthException("Failed to retrieve token response: " + e.getMessage(), e); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/ThingsTemplateGenerator.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/ThingsTemplateGenerator.java new file mode 100644 index 0000000000000..613466ce6d181 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/ThingsTemplateGenerator.java @@ -0,0 +1,121 @@ +/** + * 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.mielecloud.internal.config; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mielecloud.internal.MieleCloudBindingConstants; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; + +/** + * Generator for templates which can be copy-pasted into .things files by the user. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public class ThingsTemplateGenerator { + /** + * Creates a template for the bridge. + * + * @param bridgeId Id of the bridge (last part of the thing UID). + * @param locale Locale for accessing the Miele cloud service. + * @return The template. + */ + public String createBridgeConfigurationTemplate(String bridgeId, String email, String locale) { + var builder = new StringBuilder(); + builder.append("Bridge "); + builder.append(MieleCloudBindingConstants.THING_TYPE_BRIDGE.getAsString()); + builder.append(":"); + builder.append(bridgeId); + builder.append(" [ email=\""); + builder.append(email); + builder.append("\", locale=\""); + builder.append(locale); + builder.append("\" ]"); + return builder.toString(); + } + + /** + * Creates a complete template containing the bridge and all paired devices. + * + * @param bridge The bridge which is used to pair the things. + * @param pairedThings The paired things. + * @param discoveryResults The discovery results which can be paired. + * @return The template. + */ + public String createBridgeAndThingConfigurationTemplate(Bridge bridge, List pairedThings, + List discoveryResults) { + StringBuilder result = new StringBuilder(); + result.append(createBridgeConfigurationTemplate(bridge.getUID().getId(), + bridge.getConfiguration().get(MieleCloudBindingConstants.CONFIG_PARAM_EMAIL).toString(), + getLocale(bridge))); + result.append(" {\n"); + + for (Thing thing : pairedThings) { + result.append(" ").append(createThingConfigurationTemplate(thing)).append("\n"); + } + + for (DiscoveryResult discoveryResult : discoveryResults) { + result.append(" ").append(createThingConfigurationTemplate(discoveryResult)).append("\n"); + } + + result.append("}"); + return result.toString(); + } + + private String getLocale(Bridge bridge) { + var locale = bridge.getConfiguration().get(MieleCloudBindingConstants.CONFIG_PARAM_LOCALE); + if (locale instanceof String) { + return (String) locale; + } else { + return "en"; + } + } + + private String createThingConfigurationTemplate(Thing thing) { + StringBuilder result = new StringBuilder(); + result.append("Thing ").append(thing.getThingTypeUID().getId()).append(" ").append(thing.getUID().getId()) + .append(" "); + + final String label = thing.getLabel(); + if (label != null) { + result.append("\"").append(label).append("\" "); + } + + result.append("[ "); + result.append("deviceIdentifier=\""); + result.append( + thing.getConfiguration().get(MieleCloudBindingConstants.CONFIG_PARAM_DEVICE_IDENTIFIER).toString()); + result.append("\""); + result.append(" ]"); + return result.toString(); + } + + private String createThingConfigurationTemplate(DiscoveryResult discoveryResult) { + return "Thing " + discoveryResult.getThingTypeUID().getId() + " " + discoveryResult.getThingUID().getId() + + " \"" + discoveryResult.getLabel() + "\" [ deviceIdentifier=\"" + + getProperty(discoveryResult, MieleCloudBindingConstants.CONFIG_PARAM_DEVICE_IDENTIFIER) + "\" ]"; + } + + private String getProperty(DiscoveryResult discoveryResult, String propertyName) { + var value = discoveryResult.getProperties().get(MieleCloudBindingConstants.CONFIG_PARAM_DEVICE_IDENTIFIER); + if (value == null) { + return ""; + } else { + return value.toString(); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/BridgeCreationFailedException.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/BridgeCreationFailedException.java new file mode 100644 index 0000000000000..54c2263674869 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/BridgeCreationFailedException.java @@ -0,0 +1,25 @@ +/** + * 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.mielecloud.internal.config.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown when a bridge cannot be created in the configuration flow. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class BridgeCreationFailedException extends RuntimeException { + private static final long serialVersionUID = -6150154333256723312L; +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/BridgeReconfigurationFailedException.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/BridgeReconfigurationFailedException.java new file mode 100644 index 0000000000000..97f66aae232a6 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/BridgeReconfigurationFailedException.java @@ -0,0 +1,29 @@ +/** + * 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.mielecloud.internal.config.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown when reconfiguring an existing bridge fails. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public class BridgeReconfigurationFailedException extends RuntimeException { + private static final long serialVersionUID = -6341258448724364940L; + + public BridgeReconfigurationFailedException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/NoOngoingAuthorizationException.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/NoOngoingAuthorizationException.java new file mode 100644 index 0000000000000..2ba3768b7148d --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/NoOngoingAuthorizationException.java @@ -0,0 +1,29 @@ +/** + * 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.mielecloud.internal.config.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown when no authorization is ongoing. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public class NoOngoingAuthorizationException extends RuntimeException { + private static final long serialVersionUID = 3074275827393542416L; + + public NoOngoingAuthorizationException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/OngoingAuthorizationException.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/OngoingAuthorizationException.java new file mode 100644 index 0000000000000..e232ee50db937 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/exception/OngoingAuthorizationException.java @@ -0,0 +1,50 @@ +/** + * 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.mielecloud.internal.config.exception; + +import java.time.LocalDateTime; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Exception thrown when there already is an ongoing authorization process. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class OngoingAuthorizationException extends RuntimeException { + private static final long serialVersionUID = -6742384930140134244L; + + @Nullable + private final LocalDateTime ongoingAuthorizationExpiryTimestamp; + + /** + * Creates a new {@link OngoingAuthorizationException}. + * + * @param message Exception message. + * @param ongoingAuthorizationExpiryTimestamp Timestamp when the ongoing authorization will expire. + */ + public OngoingAuthorizationException(String message, @Nullable LocalDateTime ongoingAuthorizationExpiryTimestamp) { + super(message); + this.ongoingAuthorizationExpiryTimestamp = ongoingAuthorizationExpiryTimestamp; + } + + /** + * Gets the timestamp representing when the ongoing authorization will expire. + */ + @Nullable + public LocalDateTime getOngoingAuthorizationExpiryTimestamp() { + return ongoingAuthorizationExpiryTimestamp; + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AbstractRedirectionServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AbstractRedirectionServlet.java new file mode 100644 index 0000000000000..d8a40908731d5 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AbstractRedirectionServlet.java @@ -0,0 +1,62 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for servlets that have no visible frontend and just serve the purpose of redirecting the user to another + * website. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public abstract class AbstractRedirectionServlet extends HttpServlet { + private static final long serialVersionUID = 4280026301732437523L; + + private final Logger logger = LoggerFactory.getLogger(AbstractRedirectionServlet.class); + + @Override + protected void doGet(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response) + throws ServletException, IOException { + if (response == null) { + logger.warn("Ignoring received request without response."); + return; + } + if (request == null) { + logger.warn("Ignoring illegal request."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + response.sendRedirect(getRedirectionDestination(request)); + } + + /** + * Gets the redirection destination. This can be a relative or absolute path or a link to another website. + * + * @param request The original request sent by the browser. + * @return The redirection destination. + */ + protected abstract String getRedirectionDestination(HttpServletRequest request); +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AbstractShowPageServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AbstractShowPageServlet.java new file mode 100644 index 0000000000000..8faa7bb2cb9f7 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AbstractShowPageServlet.java @@ -0,0 +1,93 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for servlets that show a visible frontend in the browser. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public abstract class AbstractShowPageServlet extends HttpServlet { + private static final long serialVersionUID = 3820684716753275768L; + + private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; + + private final Logger logger = LoggerFactory.getLogger(AbstractShowPageServlet.class); + + private final ResourceLoader resourceLoader; + + protected ResourceLoader getResourceLoader() { + return resourceLoader; + } + + /** + * Creates a new {@link AbstractShowPageServlet}. + * + * @param resourceLoader Loader for resource files. + */ + public AbstractShowPageServlet(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + @Override + protected void doGet(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response) + throws ServletException, IOException { + if (response == null) { + logger.warn("Ignoring received request without response."); + return; + } + if (request == null) { + logger.warn("Ignoring illegal request."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + try { + String html = handleGetRequest(request, response); + response.setContentType(CONTENT_TYPE); + response.getWriter().write(html); + response.getWriter().close(); + } catch (MieleHttpException e) { + response.sendError(e.getHttpErrorCode()); + } catch (IOException e) { + logger.warn("Failed to load resources.", e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + /** + * Handles a GET request. + * + * @param request The request. + * @param response The response. + * @return A rendered HTML body to be displayed in the browser. The body will be framed by the binding's frontend + * layout. + * @throws MieleHttpException if an error occurs that should be handled by sending a default error response. + * @throws IOException if an error occurs while loading resources. + */ + protected abstract String handleGetRequest(HttpServletRequest request, HttpServletResponse response) + throws MieleHttpException, IOException; +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AccountOverviewServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AccountOverviewServlet.java new file mode 100644 index 0000000000000..8944e0d3f84f8 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/AccountOverviewServlet.java @@ -0,0 +1,182 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mielecloud.internal.MieleCloudBindingConstants; +import org.openhab.binding.mielecloud.internal.config.ThingsTemplateGenerator; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.inbox.Inbox; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingStatus; + +/** + * Servlet showing the account overview page. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class AccountOverviewServlet extends AbstractShowPageServlet { + private static final long serialVersionUID = -4551210904923220429L; + private static final String ACCOUNTS_SKELETON = "index.html"; + + private static final String BRIDGES_TITLE_PLACEHOLDER = ""; + private static final String BRIDGES_PLACEHOLDER = ""; + private static final String NO_SSL_WARNING_PLACEHOLDER = ""; + + private final ThingRegistry thingRegistry; + private final Inbox inbox; + private final ThingsTemplateGenerator templateGenerator; + + /** + * Creates a new {@link AccountOverviewServlet}. + * + * @param resourceLoader Loader to use for resources. + * @param thingRegistry openHAB thing registry. + * @param inbox openHAB inbox for discovery results. + */ + public AccountOverviewServlet(ResourceLoader resourceLoader, ThingRegistry thingRegistry, Inbox inbox) { + super(resourceLoader); + this.thingRegistry = thingRegistry; + this.inbox = inbox; + this.templateGenerator = new ThingsTemplateGenerator(); + } + + @Override + protected String handleGetRequest(HttpServletRequest request, HttpServletResponse response) + throws MieleHttpException, IOException { + String skeleton = getResourceLoader().loadResourceAsString(ACCOUNTS_SKELETON); + skeleton = renderBridges(skeleton); + skeleton = renderSslWarning(request, skeleton); + return skeleton; + } + + private String renderBridges(String skeleton) { + List bridges = thingRegistry.stream().filter(this::isMieleCloudBridge).collect(Collectors.toList()); + if (bridges.isEmpty()) { + return renderNoBridges(skeleton); + } else { + return renderBridgesIntoSkeleton(skeleton, bridges); + } + } + + private String renderNoBridges(String skeleton) { + return skeleton.replace(BRIDGES_TITLE_PLACEHOLDER, "There is no account paired at the moment.") + .replace(BRIDGES_PLACEHOLDER, ""); + } + + private String renderBridgesIntoSkeleton(String skeleton, List bridges) { + StringBuilder builder = new StringBuilder(); + + int index = 0; + Iterator bridgeIterator = bridges.iterator(); + while (bridgeIterator.hasNext()) { + builder.append(renderBridge(bridgeIterator.next(), index)); + index++; + } + + return skeleton.replace(BRIDGES_TITLE_PLACEHOLDER, "The following bridges are paired") + .replace(BRIDGES_PLACEHOLDER, builder.toString()); + } + + private String renderBridge(Thing bridge, int index) { + StringBuilder builder = new StringBuilder(); + builder.append("
  • \n"); + + String thingUid = bridge.getUID().getAsString(); + String thingId = bridge.getUID().getId(); + builder.append(" "); + builder.append(thingUid.substring(0, thingUid.length() - thingId.length())); + builder.append(" "); + builder.append(thingId); + builder.append(" "); + builder.append(bridge.getConfiguration().get(MieleCloudBindingConstants.CONFIG_PARAM_EMAIL).toString()); + builder.append("\n"); + + builder.append(" "); + builder.append(status.toString()); + builder.append("\n"); + + builder.append(" \n"); + + builder.append(" \n"); + + builder.append("
    \n"); + builder.append( + " You can use this things-file template to pair all available devices:\n"); + builder.append("
    \n"); + builder.append( + " Copy\n"); + builder.append(" \n"); + builder.append("
    \n"); + builder.append("
    \n"); + builder.append("
  • "); + + return builder.toString(); + } + + private String generateConfigurationTemplate(Bridge bridge) { + List pairedThings = thingRegistry.stream().filter(thing -> isConnectedVia(thing, bridge)) + .collect(Collectors.toList()); + List discoveryResults = inbox.stream() + .filter(discoveryResult -> willConnectVia(discoveryResult, bridge)).collect(Collectors.toList()); + + return templateGenerator.createBridgeAndThingConfigurationTemplate(bridge, pairedThings, discoveryResults); + } + + private boolean isConnectedVia(Thing thing, Bridge bridge) { + return bridge.getUID().equals(thing.getBridgeUID()); + } + + private boolean willConnectVia(DiscoveryResult discoveryResult, Bridge bridge) { + return bridge.getUID().equals(discoveryResult.getBridgeUID()); + } + + private boolean isMieleCloudBridge(Thing thing) { + return MieleCloudBindingConstants.THING_TYPE_BRIDGE.equals(thing.getThingTypeUID()); + } + + private String renderSslWarning(HttpServletRequest request, String skeleton) { + if (!request.isSecure()) { + return skeleton.replace(NO_SSL_WARNING_PLACEHOLDER, "
    \n" + + " Warning: We strongly advice to proceed only with SSL enabled for a secure data exchange.\n" + + " See Securing access to openHAB for details.\n" + + "
    "); + } else { + return skeleton.replace(NO_SSL_WARNING_PLACEHOLDER, ""); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/CreateBridgeServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/CreateBridgeServlet.java new file mode 100644 index 0000000000000..3b667ce183da1 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/CreateBridgeServlet.java @@ -0,0 +1,217 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mielecloud.internal.MieleCloudBindingConstants; +import org.openhab.binding.mielecloud.internal.config.exception.BridgeCreationFailedException; +import org.openhab.binding.mielecloud.internal.config.exception.BridgeReconfigurationFailedException; +import org.openhab.binding.mielecloud.internal.handler.MieleBridgeHandler; +import org.openhab.binding.mielecloud.internal.util.EmailValidator; +import org.openhab.binding.mielecloud.internal.util.LocaleValidator; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.inbox.Inbox; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Servlet that automatically creates a bridge and then redirects the browser to the account overview page. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class CreateBridgeServlet extends AbstractRedirectionServlet { + private static final String MIELE_CLOUD_BRIDGE_NAME = "Cloud Connector"; + private static final String MIELE_CLOUD_BRIDGE_LABEL = "Miele@home Account"; + + private static final String LOCALE_PARAMETER_NAME = "locale"; + public static final String BRIDGE_UID_PARAMETER_NAME = "bridgeUid"; + public static final String EMAIL_PARAMETER_NAME = "email"; + + private static final long serialVersionUID = -2912042079128722887L; + + private static final String DEFAULT_LOCALE = "en"; + + private static final long ONLINE_WAIT_TIMEOUT_IN_MILLISECONDS = 5000; + private static final long DISCOVERY_COMPLETION_TIMEOUT_IN_MILLISECONDS = 5000; + private static final long CHECK_INTERVAL_IN_MILLISECONDS = 100; + + private final Logger logger = LoggerFactory.getLogger(CreateBridgeServlet.class); + + private final Inbox inbox; + private final ThingRegistry thingRegistry; + + /** + * Creates a new {@link CreateBridgeServlet}. + * + * @param inbox openHAB inbox for discovery results. + * @param thingRegistry openHAB thing registry. + */ + public CreateBridgeServlet(Inbox inbox, ThingRegistry thingRegistry) { + this.inbox = inbox; + this.thingRegistry = thingRegistry; + } + + @Override + protected String getRedirectionDestination(HttpServletRequest request) { + String bridgeUidString = request.getParameter(BRIDGE_UID_PARAMETER_NAME); + if (bridgeUidString == null || bridgeUidString.isEmpty()) { + logger.warn("Cannot create bridge: Bridge UID is missing."); + return "/mielecloud/failure?" + FailureServlet.MISSING_BRIDGE_UID_PARAMETER_NAME + "=true"; + } + + String email = request.getParameter(EMAIL_PARAMETER_NAME); + if (email == null || email.isEmpty()) { + logger.warn("Cannot create bridge: E-mail address is missing."); + return "/mielecloud/failure?" + FailureServlet.MISSING_EMAIL_PARAMETER_NAME + "=true"; + } + + ThingUID bridgeUid = null; + try { + bridgeUid = new ThingUID(bridgeUidString); + } catch (IllegalArgumentException e) { + logger.warn("Cannot create bridge: Bridge UID '{}' is malformed.", bridgeUid); + return "/mielecloud/failure?" + FailureServlet.MALFORMED_BRIDGE_UID_PARAMETER_NAME + "=true"; + } + + if (!EmailValidator.isValid(email)) { + logger.warn("Cannot create bridge: E-mail address '{}' is malformed.", email); + return "/mielecloud/failure?" + FailureServlet.MALFORMED_EMAIL_PARAMETER_NAME + "=true"; + } + + String locale = getValidLocale(request.getParameter(LOCALE_PARAMETER_NAME)); + + logger.debug("Auto configuring Miele account using locale '{}' (requested locale was '{}')", locale, + request.getParameter(LOCALE_PARAMETER_NAME)); + try { + Thing bridge = pairOrReconfigureBridge(locale, bridgeUid, email); + waitForBridgeToComeOnline(bridge); + return "/mielecloud"; + } catch (BridgeReconfigurationFailedException e) { + logger.warn("{}", e.getMessage()); + return "/mielecloud/success?" + SuccessServlet.BRIDGE_RECONFIGURATION_FAILED_PARAMETER_NAME + "=true&" + + SuccessServlet.BRIDGE_UID_PARAMETER_NAME + "=" + bridgeUidString + "&" + + SuccessServlet.EMAIL_PARAMETER_NAME + "=" + email; + } catch (BridgeCreationFailedException e) { + logger.warn("Thing creation failed because there was no binding available that supports the thing."); + return "/mielecloud/success?" + SuccessServlet.BRIDGE_CREATION_FAILED_PARAMETER_NAME + "=true&" + + SuccessServlet.BRIDGE_UID_PARAMETER_NAME + "=" + bridgeUidString + "&" + + SuccessServlet.EMAIL_PARAMETER_NAME + "=" + email; + } + } + + private Thing pairOrReconfigureBridge(String locale, ThingUID bridgeUid, String email) { + DiscoveryResult result = DiscoveryResultBuilder.create(bridgeUid) + .withRepresentationProperty(Thing.PROPERTY_MODEL_ID).withLabel(MIELE_CLOUD_BRIDGE_LABEL) + .withProperty(Thing.PROPERTY_MODEL_ID, MIELE_CLOUD_BRIDGE_NAME) + .withProperty(MieleCloudBindingConstants.CONFIG_PARAM_LOCALE, locale) + .withProperty(MieleCloudBindingConstants.CONFIG_PARAM_EMAIL, email).build(); + if (inbox.add(result)) { + return pairBridge(bridgeUid); + } else { + return reconfigureBridge(bridgeUid, locale, email); + } + } + + private Thing pairBridge(ThingUID thingUid) { + Thing thing = inbox.approve(thingUid, MIELE_CLOUD_BRIDGE_LABEL, null); + if (thing == null) { + throw new BridgeCreationFailedException(); + } + + logger.debug("Successfully created bridge {}", thingUid); + return thing; + } + + private Thing reconfigureBridge(ThingUID thingUid, String locale, String email) { + logger.debug("Thing already exists. Modifying configuration."); + Thing thing = thingRegistry.get(thingUid); + if (thing == null) { + throw new BridgeReconfigurationFailedException( + "Cannot modify non existing bridge: Could neither add bridge via inbox nor find existing bridge."); + } + + ThingHandler handler = thing.getHandler(); + if (handler == null) { + throw new BridgeReconfigurationFailedException("Bridge exists but has no handler."); + } + if (!(handler instanceof MieleBridgeHandler)) { + throw new BridgeReconfigurationFailedException("Bridge handler is of wrong type, expected '" + + MieleBridgeHandler.class.getSimpleName() + "' but got '" + handler.getClass().getName() + "'."); + } + + MieleBridgeHandler bridgeHandler = (MieleBridgeHandler) handler; + bridgeHandler.disposeWebservice(); + bridgeHandler.initializeWebservice(); + + return thing; + } + + private String getValidLocale(@Nullable String localeParameterValue) { + if (localeParameterValue == null || localeParameterValue.isEmpty() + || !LocaleValidator.isValidLanguage(localeParameterValue)) { + return DEFAULT_LOCALE; + } else { + return localeParameterValue; + } + } + + private void waitForBridgeToComeOnline(Thing bridge) { + try { + waitForConditionWithTimeout(() -> bridge.getStatus() == ThingStatus.ONLINE, + ONLINE_WAIT_TIMEOUT_IN_MILLISECONDS); + waitForConditionWithTimeout(new DiscoveryResultCountDoesNotChangeCondition(), + DISCOVERY_COMPLETION_TIMEOUT_IN_MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void waitForConditionWithTimeout(BooleanSupplier condition, long timeoutInMilliseconds) + throws InterruptedException { + long remainingWaitTime = timeoutInMilliseconds; + while (!condition.getAsBoolean() && remainingWaitTime > 0) { + TimeUnit.MILLISECONDS.sleep(CHECK_INTERVAL_IN_MILLISECONDS); + remainingWaitTime -= CHECK_INTERVAL_IN_MILLISECONDS; + } + } + + private class DiscoveryResultCountDoesNotChangeCondition implements BooleanSupplier { + private long previousDiscoveryResultCount = 0; + + @Override + public boolean getAsBoolean() { + var discoveryResultCount = countOwnDiscoveryResults(); + var discoveryResultCountUnchanged = previousDiscoveryResultCount == discoveryResultCount; + previousDiscoveryResultCount = discoveryResultCount; + return discoveryResultCountUnchanged; + } + + private long countOwnDiscoveryResults() { + return inbox.stream().map(DiscoveryResult::getBindingId) + .filter(MieleCloudBindingConstants.BINDING_ID::equals).count(); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/FailureServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/FailureServlet.java new file mode 100644 index 0000000000000..a24802b3b298f --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/FailureServlet.java @@ -0,0 +1,116 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.IOException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Servlet showing a failure page. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public class FailureServlet extends AbstractShowPageServlet { + private static final long serialVersionUID = -5195984256535664942L; + + public static final String OAUTH2_ERROR_PARAMETER_NAME = "oauth2Error"; + public static final String ILLEGAL_RESPONSE_PARAMETER_NAME = "illegalResponse"; + public static final String NO_ONGOING_AUTHORIZATION_PARAMETER_NAME = "noOngoingAuthorization"; + public static final String FAILED_TO_COMPLETE_AUTHORIZATION_PARAMETER_NAME = "failedToCompleteAuthorization"; + public static final String MISSING_BRIDGE_UID_PARAMETER_NAME = "missingBridgeUid"; + public static final String MISSING_EMAIL_PARAMETER_NAME = "missingEmail"; + public static final String MALFORMED_BRIDGE_UID_PARAMETER_NAME = "malformedBridgeUid"; + public static final String MALFORMED_EMAIL_PARAMETER_NAME = "malformedEmail"; + public static final String MISSING_REQUEST_URL_PARAMETER_NAME = "missingRequestUrl"; + + public static final String OAUTH2_ERROR_ACCESS_DENIED = "access_denied"; + public static final String OAUTH2_ERROR_INVALID_REQUEST = "invalid_request"; + public static final String OAUTH2_ERROR_UNAUTHORIZED_CLIENT = "unauthorized_client"; + public static final String OAUTH2_ERROR_UNSUPPORTED_RESPONSE_TYPE = "unsupported_response_type"; + public static final String OAUTH2_ERROR_INVALID_SCOPE = "invalid_scope"; + public static final String OAUTH2_ERROR_SERVER_ERROR = "server_error"; + public static final String OAUTH2_ERROR_TEMPORARY_UNAVAILABLE = "temporarily_unavailable"; + + private static final String ERROR_MESSAGE_TEXT_PLACEHOLDER = ""; + + /** + * Creates a new {@link FailureServlet}. + * + * @param resourceLoader Loader to use for resources. + */ + public FailureServlet(ResourceLoader resourceLoader) { + super(resourceLoader); + } + + @Override + protected String handleGetRequest(HttpServletRequest request, HttpServletResponse response) + throws MieleHttpException, IOException { + return getResourceLoader().loadResourceAsString("failure.html").replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + getErrorMessage(request)); + } + + private String getErrorMessage(HttpServletRequest request) { + String oauth2Error = request.getParameter(OAUTH2_ERROR_PARAMETER_NAME); + if (oauth2Error != null) { + return getOAuth2ErrorMessage(oauth2Error); + } else if (ServletUtil.isParameterEnabled(request, ILLEGAL_RESPONSE_PARAMETER_NAME)) { + return "Miele cloud service returned an illegal response."; + } else if (ServletUtil.isParameterEnabled(request, NO_ONGOING_AUTHORIZATION_PARAMETER_NAME)) { + return "There is no ongoing authorization. Please start an authorization first."; + } else if (ServletUtil.isParameterEnabled(request, FAILED_TO_COMPLETE_AUTHORIZATION_PARAMETER_NAME)) { + return "Completing the final authorization request failed. Please try the config flow again."; + } else if (ServletUtil.isParameterEnabled(request, MISSING_BRIDGE_UID_PARAMETER_NAME)) { + return "Missing bridge UID."; + } else if (ServletUtil.isParameterEnabled(request, MISSING_EMAIL_PARAMETER_NAME)) { + return "Missing e-mail address."; + } else if (ServletUtil.isParameterEnabled(request, MALFORMED_BRIDGE_UID_PARAMETER_NAME)) { + return "Malformed bridge UID."; + } else if (ServletUtil.isParameterEnabled(request, MALFORMED_EMAIL_PARAMETER_NAME)) { + return "Malformed e-mail address."; + } else if (ServletUtil.isParameterEnabled(request, MISSING_REQUEST_URL_PARAMETER_NAME)) { + return "Missing request URL. Please try the config flow again."; + } else { + return "Unknown error."; + } + } + + private String getOAuth2ErrorMessage(String oauth2Error) { + return "OAuth2 authentication with Miele cloud service failed: " + getOAuth2ErrorDetailMessage(oauth2Error); + } + + private String getOAuth2ErrorDetailMessage(String oauth2Error) { + switch (oauth2Error) { + case OAUTH2_ERROR_ACCESS_DENIED: + return "Access denied."; + case OAUTH2_ERROR_INVALID_REQUEST: + return "Malformed request."; + case OAUTH2_ERROR_UNAUTHORIZED_CLIENT: + return "Account not authorized to request authorization code."; + case OAUTH2_ERROR_UNSUPPORTED_RESPONSE_TYPE: + return "Obtaining an authorization code is not supported."; + case OAUTH2_ERROR_INVALID_SCOPE: + return "Invalid scope."; + case OAUTH2_ERROR_SERVER_ERROR: + return "Unexpected server error."; + case OAUTH2_ERROR_TEMPORARY_UNAVAILABLE: + return "Authorization server temporarily unavailable."; + default: + return "Unknown error code \"" + oauth2Error + "\"."; + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ForwardToLoginServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ForwardToLoginServlet.java new file mode 100644 index 0000000000000..e817463adf87d --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ForwardToLoginServlet.java @@ -0,0 +1,148 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mielecloud.internal.MieleCloudBindingConstants; +import org.openhab.binding.mielecloud.internal.auth.OAuthException; +import org.openhab.binding.mielecloud.internal.config.OAuthAuthorizationHandler; +import org.openhab.binding.mielecloud.internal.config.exception.NoOngoingAuthorizationException; +import org.openhab.binding.mielecloud.internal.config.exception.OngoingAuthorizationException; +import org.openhab.binding.mielecloud.internal.util.EmailValidator; +import org.openhab.core.thing.ThingUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Servlet gathers and processes required information to perform an authorization with the Miele cloud service + * and create a bridge afterwards. Required parameters are the client ID, client secret, an ID for the bridge and an + * e-mail address. If the given parameters are valid, the browser is redirected to the Miele service login. Otherwise, + * the browser is redirected to the previous page with an according error message. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class ForwardToLoginServlet extends AbstractRedirectionServlet { + private static final long serialVersionUID = -9094642228439994183L; + + public static final String CLIENT_ID_PARAMETER_NAME = "clientId"; + public static final String CLIENT_SECRET_PARAMETER_NAME = "clientSecret"; + public static final String BRIDGE_ID_PARAMETER_NAME = "bridgeId"; + public static final String EMAIL_PARAMETER_NAME = "email"; + + private final Logger logger = LoggerFactory.getLogger(ForwardToLoginServlet.class); + + private final OAuthAuthorizationHandler authorizationHandler; + + /** + * Creates a new {@link ForwardToLoginServlet}. + * + * @param authorizationHandler Handler implementing the OAuth authorization process. + */ + public ForwardToLoginServlet(OAuthAuthorizationHandler authorizationHandler) { + this.authorizationHandler = authorizationHandler; + } + + @Override + protected String getRedirectionDestination(HttpServletRequest request) { + String clientId = request.getParameter(CLIENT_ID_PARAMETER_NAME); + String clientSecret = request.getParameter(CLIENT_SECRET_PARAMETER_NAME); + String bridgeId = request.getParameter(BRIDGE_ID_PARAMETER_NAME); + String email = request.getParameter(EMAIL_PARAMETER_NAME); + + if (clientId == null || clientId.isEmpty()) { + logger.warn("Request is missing client ID."); + return getErrorRedirectionUrl(PairAccountServlet.MISSING_CLIENT_ID_PARAMETER_NAME); + } + if (clientSecret == null || clientSecret.isEmpty()) { + logger.warn("Request is missing client secret."); + return getErrorRedirectionUrl(PairAccountServlet.MISSING_CLIENT_SECRET_PARAMETER_NAME); + } + if (bridgeId == null || bridgeId.isEmpty()) { + logger.warn("Request is missing bridge ID."); + return getErrorRedirectionUrl(PairAccountServlet.MISSING_BRIDGE_ID_PARAMETER_NAME); + } + if (email == null || email.isEmpty()) { + logger.warn("Request is missing e-mail address."); + return getErrorRedirectionUrl(PairAccountServlet.MISSING_EMAIL_PARAMETER_NAME); + } + + ThingUID bridgeUid = null; + try { + bridgeUid = new ThingUID(MieleCloudBindingConstants.THING_TYPE_BRIDGE, bridgeId); + } catch (IllegalArgumentException e) { + logger.warn("Passed bridge ID '{}' is invalid.", bridgeId); + return getErrorRedirectionUrl(PairAccountServlet.MALFORMED_BRIDGE_ID_PARAMETER_NAME); + } + + if (!EmailValidator.isValid(email)) { + logger.warn("Passed e-mail address '{}' is invalid.", email); + return getErrorRedirectionUrl(PairAccountServlet.MALFORMED_EMAIL_PARAMETER_NAME); + } + + try { + authorizationHandler.beginAuthorization(clientId, clientSecret, bridgeUid, email); + } catch (OngoingAuthorizationException e) { + logger.warn("Cannot begin new authorization process while another one is still running."); + return getErrorRedirectUrlWithExpiryTime(e.getOngoingAuthorizationExpiryTimestamp()); + } + + StringBuffer requestUrl = request.getRequestURL(); + if (requestUrl == null) { + return getErrorRedirectionUrl(PairAccountServlet.MISSING_REQUEST_URL_PARAMETER_NAME); + } + + try { + return authorizationHandler.getAuthorizationUrl(deriveRedirectUri(requestUrl.toString())); + } catch (NoOngoingAuthorizationException e) { + logger.warn( + "Failed to create authorization URL: There was no ongoing authorization although we just started one."); + return getErrorRedirectionUrl(PairAccountServlet.NO_ONGOING_AUTHORIZATION_IN_STEP2_PARAMETER_NAME); + } catch (OAuthException e) { + logger.warn("Failed to create authorization URL.", e); + return getErrorRedirectionUrl(PairAccountServlet.FAILED_TO_DERIVE_REDIRECT_URL_PARAMETER_NAME); + } + } + + private String getErrorRedirectUrlWithExpiryTime(@Nullable LocalDateTime ongoingAuthorizationExpiryTimestamp) { + if (ongoingAuthorizationExpiryTimestamp == null) { + return getErrorRedirectionUrl( + PairAccountServlet.ONGOING_AUTHORIZATION_IN_STEP1_EXPIRES_IN_MINUTES_PARAMETER_NAME, + PairAccountServlet.ONGOING_AUTHORIZATION_UNKNOWN_EXPIRY_TIME); + } + + long minutesUntilExpiry = ChronoUnit.MINUTES.between(LocalDateTime.now(), ongoingAuthorizationExpiryTimestamp) + + 1; + return getErrorRedirectionUrl( + PairAccountServlet.ONGOING_AUTHORIZATION_IN_STEP1_EXPIRES_IN_MINUTES_PARAMETER_NAME, + Long.toString(minutesUntilExpiry)); + } + + private String getErrorRedirectionUrl(String errorCode) { + return getErrorRedirectionUrl(errorCode, "true"); + } + + private String getErrorRedirectionUrl(String errorCode, String parameterValue) { + return "/mielecloud/pair?" + errorCode + "=" + parameterValue; + } + + private String deriveRedirectUri(String requestUrl) { + return requestUrl + "/../result"; + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/MieleHttpException.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/MieleHttpException.java new file mode 100644 index 0000000000000..c5eff7bc6697b --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/MieleHttpException.java @@ -0,0 +1,35 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception wrapping a HTTP error code for further processing. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class MieleHttpException extends Exception { + private static final long serialVersionUID = 1825214275413952809L; + + private final int httpErrorCode; + + public MieleHttpException(int httpErrorCode) { + this.httpErrorCode = httpErrorCode; + } + + public int getHttpErrorCode() { + return httpErrorCode; + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/PairAccountServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/PairAccountServlet.java new file mode 100644 index 0000000000000..79d872a7caf4a --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/PairAccountServlet.java @@ -0,0 +1,124 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.IOException; +import java.util.Optional; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Servlet showing the pair account page. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class PairAccountServlet extends AbstractShowPageServlet { + private static final long serialVersionUID = 6565378471951635420L; + + public static final String CLIENT_ID_PARAMETER_NAME = "clientId"; + public static final String CLIENT_SECRET_PARAMETER_NAME = "clientSecret"; + + public static final String MISSING_CLIENT_ID_PARAMETER_NAME = "missingClientId"; + public static final String MISSING_CLIENT_SECRET_PARAMETER_NAME = "missingClientSecret"; + public static final String MISSING_BRIDGE_ID_PARAMETER_NAME = "missingBridgeId"; + public static final String MISSING_EMAIL_PARAMETER_NAME = "missingEmail"; + public static final String MALFORMED_BRIDGE_ID_PARAMETER_NAME = "malformedBridgeId"; + public static final String MALFORMED_EMAIL_PARAMETER_NAME = "malformedEmail"; + public static final String FAILED_TO_DERIVE_REDIRECT_URL_PARAMETER_NAME = "failedToDeriveRedirectUrl"; + public static final String ONGOING_AUTHORIZATION_IN_STEP1_EXPIRES_IN_MINUTES_PARAMETER_NAME = "ongoingAuthorizationInStep1ExpiresInMinutes"; + public static final String ONGOING_AUTHORIZATION_UNKNOWN_EXPIRY_TIME = "unknown"; + public static final String NO_ONGOING_AUTHORIZATION_IN_STEP2_PARAMETER_NAME = "noOngoingAuthorizationInStep2"; + public static final String MISSING_REQUEST_URL_PARAMETER_NAME = "missingRequestUrl"; + + private static final String PAIR_ACCOUNT_SKELETON = "pairing.html"; + + private static final String CLIENT_ID_PLACEHOLDER = ""; + private static final String CLIENT_SECRET_PLACEHOLDER = ""; + private static final String ERROR_MESSAGE_PLACEHOLDER = ""; + + /** + * Creates a new {@link PairAccountServlet}. + * + * @param resourceLoader Loader for resources. + */ + public PairAccountServlet(ResourceLoader resourceLoader) { + super(resourceLoader); + } + + @Override + protected String handleGetRequest(HttpServletRequest request, HttpServletResponse response) + throws MieleHttpException, IOException { + String skeleton = getResourceLoader().loadResourceAsString(PAIR_ACCOUNT_SKELETON); + skeleton = renderClientIdAndClientSecret(request, skeleton); + skeleton = renderErrorMessage(request, skeleton); + return skeleton; + } + + private String renderClientIdAndClientSecret(HttpServletRequest request, String skeleton) { + String prefilledClientId = Optional.ofNullable(request.getParameter(CLIENT_ID_PARAMETER_NAME)).orElse(""); + String prefilledClientSecret = Optional.ofNullable(request.getParameter(CLIENT_SECRET_PARAMETER_NAME)) + .orElse(""); + return skeleton.replace(CLIENT_ID_PLACEHOLDER, prefilledClientId).replace(CLIENT_SECRET_PLACEHOLDER, + prefilledClientSecret); + } + + private String renderErrorMessage(HttpServletRequest request, String skeleton) { + if (ServletUtil.isParameterEnabled(request, MISSING_CLIENT_ID_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Missing client ID.
    "); + } else if (ServletUtil.isParameterEnabled(request, MISSING_CLIENT_SECRET_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + + "
    Missing client secret.
    "); + } else if (ServletUtil.isParameterEnabled(request, MISSING_BRIDGE_ID_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Missing bridge ID.
    "); + } else if (ServletUtil.isParameterEnabled(request, MISSING_EMAIL_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Missing e-mail address.
    "); + } else if (ServletUtil.isParameterEnabled(request, MALFORMED_BRIDGE_ID_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Malformed bridge ID. A bridge ID may only contain letters, numbers, '-' and '_'!
    "); + } else if (ServletUtil.isParameterEnabled(request, MALFORMED_EMAIL_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Malformed e-mail address.
    "); + } else if (ServletUtil.isParameterEnabled(request, FAILED_TO_DERIVE_REDIRECT_URL_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Failed to derive redirect URL.
    "); + } else if (ServletUtil.isParameterPresent(request, + ONGOING_AUTHORIZATION_IN_STEP1_EXPIRES_IN_MINUTES_PARAMETER_NAME)) { + String minutesUntilExpiry = request + .getParameter(ONGOING_AUTHORIZATION_IN_STEP1_EXPIRES_IN_MINUTES_PARAMETER_NAME); + if (ONGOING_AUTHORIZATION_UNKNOWN_EXPIRY_TIME.equals(minutesUntilExpiry)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    There is an authorization ongoing at the moment. Please complete that authorization prior to starting a new one or try again later.
    "); + } else { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    There is an authorization ongoing at the moment. Please complete that authorization prior to starting a new one or try again in " + + minutesUntilExpiry + " minutes.
    "); + } + } else if (ServletUtil.isParameterEnabled(request, NO_ONGOING_AUTHORIZATION_IN_STEP2_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Failed to start auhtorization process. Are you trying to perform multiple authorizations at the same time?
    "); + } else if (ServletUtil.isParameterEnabled(request, MISSING_REQUEST_URL_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, + "
    Missing request URL. Please try again.
    "); + } else { + return skeleton.replace(ERROR_MESSAGE_PLACEHOLDER, ""); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ResourceLoader.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ResourceLoader.java new file mode 100644 index 0000000000000..d93a3c9999f19 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ResourceLoader.java @@ -0,0 +1,86 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.osgi.framework.BundleContext; + +/** + * Provides access to resource files for servlets. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class ResourceLoader { + private static final String BEGINNING_OF_INPUT = "\\A"; + + private final String basePath; + private final BundleContext bundleContext; + + /** + * Creates a new {@link ResourceLoader}. + * + * @param basePath The base path to use for loading. A trailing {@code "/"} is removed. + * @param bundleContext {@link BundleContext} to load from. + */ + public ResourceLoader(String basePath, BundleContext bundleContext) { + this.basePath = removeTrailingSlashes(basePath); + this.bundleContext = bundleContext; + } + + private String removeTrailingSlashes(String value) { + String ret = value; + while (ret.endsWith("/")) { + ret = ret.substring(0, ret.length() - 1); + } + return ret; + } + + /** + * Opens a resource relative to the base path. + * + * @param filename The filename of the resource to load. + * @return A stream reading from the resource file. + * @throws FileNotFoundException If the requested resource file cannot be found. + * @throws IOException If an error occurs while opening a stream to the resource. + */ + public InputStream openResource(String filename) throws IOException { + URL url = bundleContext.getBundle().getEntry(basePath + "/" + filename); + if (url == null) { + throw new FileNotFoundException("Cannot find '" + filename + "' relative to '" + basePath + "'"); + } + + return url.openStream(); + } + + /** + * Loads the contents of a resource file as UTF-8 encoded {@link String}. + * + * @param filename The filename of the resource to load. + * @return The contents of the file. + * @throws FileNotFoundException If the requested resource file cannot be found. + * @throws IOException If an error occurs while opening a stream to the resource or reading from it. + */ + public String loadResourceAsString(String filename) throws IOException { + try (Scanner scanner = new Scanner(openResource(filename), StandardCharsets.UTF_8.name())) { + return scanner.useDelimiter(BEGINNING_OF_INPUT).next(); + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ResultServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ResultServlet.java new file mode 100644 index 0000000000000..5a5db09090967 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ResultServlet.java @@ -0,0 +1,96 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mielecloud.internal.auth.OAuthException; +import org.openhab.binding.mielecloud.internal.config.OAuthAuthorizationHandler; +import org.openhab.binding.mielecloud.internal.config.exception.NoOngoingAuthorizationException; +import org.openhab.core.thing.ThingUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Servlet processing the response by the Miele service after a login. This servlet is called as a result of a + * completed login to the Miele service and assumes that the OAuth 2 parameters are passed. Depending on the parameters + * and whether the token response can be fetched either the browser is redirected to the success or the failure page. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class ResultServlet extends AbstractRedirectionServlet { + private static final long serialVersionUID = 2157912755568949550L; + + public static final String CODE_PARAMETER_NAME = "code"; + public static final String STATE_PARAMETER_NAME = "state"; + public static final String ERROR_PARAMETER_NAME = "error"; + + private final Logger logger = LoggerFactory.getLogger(ResultServlet.class); + + private final OAuthAuthorizationHandler authorizationHandler; + + /** + * Creates a new {@link ResultServlet}. + * + * @param authorizationHandler Handler implementing the OAuth authorization. + */ + public ResultServlet(OAuthAuthorizationHandler authorizationHandler) { + this.authorizationHandler = authorizationHandler; + } + + @Override + protected String getRedirectionDestination(HttpServletRequest request) { + String error = request.getParameter(ERROR_PARAMETER_NAME); + if (error != null) { + logger.warn("Received error response: {}", error); + return "/mielecloud/failure?" + FailureServlet.OAUTH2_ERROR_PARAMETER_NAME + "=" + error; + } + + String code = request.getParameter(CODE_PARAMETER_NAME); + if (code == null) { + logger.warn("Code is null"); + return "/mielecloud/failure?" + FailureServlet.ILLEGAL_RESPONSE_PARAMETER_NAME + "=true"; + } + String state = request.getParameter(STATE_PARAMETER_NAME); + if (state == null) { + logger.warn("State is null"); + return "/mielecloud/failure?" + FailureServlet.ILLEGAL_RESPONSE_PARAMETER_NAME + "=true"; + } + + try { + ThingUID bridgeId = authorizationHandler.getBridgeUid(); + String email = authorizationHandler.getEmail(); + + StringBuffer requestUrl = request.getRequestURL(); + if (requestUrl == null) { + return "/mielecloud/failure?" + FailureServlet.MISSING_REQUEST_URL_PARAMETER_NAME + "=true"; + } + + try { + authorizationHandler.completeAuthorization(requestUrl.toString() + "?" + request.getQueryString()); + } catch (OAuthException e) { + logger.warn("Failed to complete authorization.", e); + return "/mielecloud/failure?" + FailureServlet.FAILED_TO_COMPLETE_AUTHORIZATION_PARAMETER_NAME + + "=true"; + } + + return "/mielecloud/success?" + SuccessServlet.BRIDGE_UID_PARAMETER_NAME + "=" + bridgeId.getAsString() + + "&" + SuccessServlet.EMAIL_PARAMETER_NAME + "=" + email; + } catch (NoOngoingAuthorizationException e) { + logger.warn("Failed to complete authorization: There is no ongoing authorization or it timed out"); + return "/mielecloud/failure?" + FailureServlet.NO_ONGOING_AUTHORIZATION_PARAMETER_NAME + "=true"; + } + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ServletUtil.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ServletUtil.java new file mode 100644 index 0000000000000..4441aca3d8d8a --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/ServletUtil.java @@ -0,0 +1,57 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Utility class for common servlet tasks. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public final class ServletUtil { + private ServletUtil() { + throw new UnsupportedOperationException(); + } + + /** + * Gets the value of a request parameter or returns a default if the parameter is not present. + */ + public static String getParameterValueOrDefault(HttpServletRequest request, String parameterName, + String defaultValue) { + String parameterValue = request.getParameter(parameterName); + if (parameterValue == null) { + return defaultValue; + } else { + return parameterValue; + } + } + + /** + * Checks whether a request parameter is enabled. + */ + public static boolean isParameterEnabled(HttpServletRequest request, String parameterName) { + return "true".equalsIgnoreCase(getParameterValueOrDefault(request, parameterName, "false")); + } + + /** + * Checks whether a parameter is present in a request. + */ + public static boolean isParameterPresent(HttpServletRequest request, String parameterName) { + String parameterValue = request.getParameter(parameterName); + return parameterValue != null && !parameterValue.trim().isEmpty(); + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/SuccessServlet.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/SuccessServlet.java new file mode 100644 index 0000000000000..d240f215ee738 --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/config/servlet/SuccessServlet.java @@ -0,0 +1,212 @@ +/** + * 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.mielecloud.internal.config.servlet; + +import java.io.IOException; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mielecloud.internal.config.ThingsTemplateGenerator; +import org.openhab.binding.mielecloud.internal.util.EmailValidator; +import org.openhab.binding.mielecloud.internal.webservice.language.LanguageProvider; +import org.openhab.core.thing.ThingUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Servlet showing the success page. + * + * @author Björn Lange - Initial Contribution + */ +@NonNullByDefault +public class SuccessServlet extends AbstractShowPageServlet { + private static final long serialVersionUID = 7013060161686096950L; + + public static final String BRIDGE_UID_PARAMETER_NAME = "bridgeUid"; + public static final String EMAIL_PARAMETER_NAME = "email"; + + public static final String BRIDGE_CREATION_FAILED_PARAMETER_NAME = "bridgeCreationFailed"; + public static final String BRIDGE_RECONFIGURATION_FAILED_PARAMETER_NAME = "bridgeReconfigurationFailed"; + + private static final String ERROR_MESSAGE_TEXT_PLACEHOLDER = ""; + private static final String BRIDGE_UID_PLACEHOLDER = ""; + private static final String EMAIL_PLACEHOLDER = ""; + private static final String THINGS_TEMPLATE_CODE_PLACEHOLDER = ""; + + private static final String LOCALE_OPTIONS_PLACEHOLDER = ""; + + private static final String DEFAULT_LANGUAGE = "en"; + private static final Set SUPPORTED_LANGUAGES = Set.of("da", "nl", "en", "fr", "de", "it", "nb", "es"); + + private final Logger logger = LoggerFactory.getLogger(SuccessServlet.class); + + private final LanguageProvider languageProvider; + private final ThingsTemplateGenerator templateGenerator; + + /** + * Creates a new {@link SuccessServlet}. + * + * @param resourceLoader Loader for resources. + * @param languageProvider Provider for the language to use as default selection. + */ + public SuccessServlet(ResourceLoader resourceLoader, LanguageProvider languageProvider) { + super(resourceLoader); + this.languageProvider = languageProvider; + this.templateGenerator = new ThingsTemplateGenerator(); + } + + @Override + protected String handleGetRequest(HttpServletRequest request, HttpServletResponse response) + throws MieleHttpException, IOException { + String bridgeUidString = request.getParameter(BRIDGE_UID_PARAMETER_NAME); + if (bridgeUidString == null || bridgeUidString.isEmpty()) { + logger.warn("Success page is missing bridge UID."); + return getResourceLoader().loadResourceAsString("failure.html").replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + "Missing bridge UID."); + } + + String email = request.getParameter(EMAIL_PARAMETER_NAME); + if (email == null || email.isEmpty()) { + logger.warn("Success page is missing e-mail address."); + return getResourceLoader().loadResourceAsString("failure.html").replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + "Missing e-mail address."); + } + + ThingUID bridgeUid = null; + try { + bridgeUid = new ThingUID(bridgeUidString); + } catch (IllegalArgumentException e) { + logger.warn("Success page received malformed bridge UID '{}'.", bridgeUidString); + return getResourceLoader().loadResourceAsString("failure.html").replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + "Malformed bridge UID."); + } + + if (!EmailValidator.isValid(email)) { + logger.warn("Success page received malformed e-mail address '{}'.", email); + return getResourceLoader().loadResourceAsString("failure.html").replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + "Malformed e-mail address."); + } + + String skeleton = getResourceLoader().loadResourceAsString("success.html"); + skeleton = renderErrorMessage(request, skeleton); + skeleton = renderBridgeUid(skeleton, bridgeUid); + skeleton = renderEmail(skeleton, email); + skeleton = renderLocaleSelection(skeleton); + skeleton = renderBridgeConfigurationTemplate(skeleton, bridgeUid, email); + return skeleton; + } + + private String renderErrorMessage(HttpServletRequest request, String skeleton) { + if (ServletUtil.isParameterEnabled(request, BRIDGE_CREATION_FAILED_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + "
    Could not auto configure the bridge. Failed to approve the bridge from the inbox. Please try the configuration flow again.
    "); + } else if (ServletUtil.isParameterEnabled(request, BRIDGE_RECONFIGURATION_FAILED_PARAMETER_NAME)) { + return skeleton.replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, + "
    Could not auto reconfigure the bridge. Bridge thing or thing handler is not available. Please try the configuration flow again.
    "); + } else { + return skeleton.replace(ERROR_MESSAGE_TEXT_PLACEHOLDER, ""); + } + } + + private String renderBridgeUid(String skeleton, ThingUID bridgeUid) { + return skeleton.replace(BRIDGE_UID_PLACEHOLDER, bridgeUid.getAsString()); + } + + private String renderEmail(String skeleton, String email) { + return skeleton.replace(EMAIL_PLACEHOLDER, email); + } + + private String renderLocaleSelection(String skeleton) { + String preSelectedLanguage = languageProvider.getLanguage().filter(SUPPORTED_LANGUAGES::contains) + .orElse(DEFAULT_LANGUAGE); + + return skeleton.replace(LOCALE_OPTIONS_PLACEHOLDER, + SUPPORTED_LANGUAGES.stream().map(Language::fromCode).filter(Optional::isPresent).map(Optional::get) + .sorted() + .map(language -> createOptionTag(language, preSelectedLanguage.equals(language.getCode()))) + .collect(Collectors.joining("\n"))); + } + + private String createOptionTag(Language language, boolean selected) { + String firstPart = "