mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[miio] QR, Captcha, 2FA cloud login method enhancements. (#20775)
* [miio] WIP logon with 2fa and captcha and QR Signed-off-by: Marcel Verpaalen <marcel@verpaalen.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Xiaomi Wifi devices (Mi IO) Binding
|
# Xiaomi Wifi Devices (Mi IO) Binding
|
||||||
|
|
||||||
This binding is used to control Xiaomi products implementing the Mi IO protocol.
|
This binding is used to control Xiaomi products implementing the Mi IO protocol.
|
||||||
This protocol is used for most of Xiaomi Mi Ecosystem wifi devices which is branded as MiJia.
|
This protocol is used for most of Xiaomi Mi Ecosystem wifi devices which is branded as MiJia.
|
||||||
@@ -10,14 +10,15 @@ If your Xiaomi wifi device is controlled by the mihome app, most likely it commu
|
|||||||
|
|
||||||
The following things types are available:
|
The following things types are available:
|
||||||
|
|
||||||
| ThingType | Description |
|
| ThingType | Description |
|
||||||
|------------------|--------------------------------------------------------------------------------------------------------------------------|
|
|--------------------|-------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| miio:generic | Generic type for discovered devices. Once the token is available and the device model is determined, this ThingType will automatically change to the appropriate ThingType |
|
| `miio:cloud` | Cloud Connector — manages Xiaomi cloud authentication and provides device tokens to all other things. Add one per openHAB instance. |
|
||||||
| miio:vacuum | For Xiaomi/Roborock Robot Vacuum products |
|
| `miio:generic` | Generic type for discovered devices. Once the token is available and the device model is determined, this ThingType will automatically change to the appropriate ThingType |
|
||||||
| miio:basic | For most other devices like yeelights, airpurifiers. Channels and commands are determined by database configuration |
|
| `miio:vacuum` | For Xiaomi/Roborock Robot Vacuum products |
|
||||||
| miio:gateway | Similar to basic, but with the Bridge feature, it can support to forward commands for connected devices |
|
| `miio:basic` | For most other devices like yeelights, airpurifiers. Channels and commands are determined by database configuration |
|
||||||
| miio:lumi | Thing type for subdevices connected to the gateway. Note, these devices require a defined gateway to function |
|
| `miio:gateway` | Similar to basic, but with the Bridge feature, it can support to forward commands for connected devices |
|
||||||
| miio:unsupported | For experimenting with other devices which use the Mi IO protocol or to build experimental support |
|
| `miio:lumi` | Thing type for subdevices connected to the gateway. Note, these devices require a defined gateway to function |
|
||||||
|
| `miio:unsupported` | For experimenting with other devices which use the Mi IO protocol or to build experimental support |
|
||||||
|
|
||||||
# Discovery
|
# Discovery
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ Use the one of the regional servers: cn,de,i2,tw,ru,sg,us.
|
|||||||
Multiple servers can be separated with comma, or leave blank to test all known servers.
|
Multiple servers can be separated with comma, or leave blank to test all known servers.
|
||||||
See [binding configuration](#binding-configuration) for more details about the binding config.
|
See [binding configuration](#binding-configuration) for more details about the binding config.
|
||||||
|
|
||||||
## Tokens without cloud access
|
## Tokens Without Cloud Access
|
||||||
|
|
||||||
Some devices provide the token upon discovery. This may depends on the firmware version.
|
Some devices provide the token upon discovery. This may depends on the firmware version.
|
||||||
If the device does not discover your token, it needs to be retrieved from the Mi Home app.
|
If the device does not discover your token, it needs to be retrieved from the Mi Home app.
|
||||||
@@ -54,19 +55,82 @@ Note. The Xiaomi devices change the token when inclusion is done. Hence if you g
|
|||||||
|
|
||||||
## Binding Configuration
|
## Binding Configuration
|
||||||
|
|
||||||
No binding configuration is required. However to enable cloud functionality enter your Xiaomi username, password and server(s).
|
No binding configuration is required.
|
||||||
The list of the known countries and related servers is available [in the country servers](#country-servers) section.
|
Cloud functionality is provided through the dedicated **Cloud Connector** thing (`miio:cloud`) described below.
|
||||||
|
|
||||||
After successful Xiaomi cloud login, the binding will use the connection to retrieve the required device tokens from the cloud.
|
> **Note:** Entering cloud credentials directly in the binding configuration page (username / password fields) is **deprecated** and no longer has any effect.
|
||||||
For Xiaomi vacuums the map can be visualized in openHAB using the cloud connection.
|
> Please migrate to the Cloud Connector thing as described in the [Cloud Connector Thing](#cloud-connector-thing) section.
|
||||||
|
|
||||||
To enter your cloud details go to the bindings page, click the Xiaomi Mi IO binding and than configure.
|
## Cloud Connector Thing
|
||||||

|
|
||||||
|
|
||||||
In the configuration page, enter your userID /passwd and county(s) or leave the countries servers blank.
|
Cloud access — needed to retrieve device tokens and to show vacuum maps — is managed by a dedicated `miio:cloud` thing called the **Cloud Connector**.
|
||||||

|
|
||||||
|
|
||||||
The binding also supports the discovery of devices via the cloud. This may be useful if the device is on a separate subnet. (note, after accepting such a device on a different subnet, the communication needs to be set to cloud in order to have it working.)
|
### Why a Separate Thing?
|
||||||
|
|
||||||
|
The Cloud Connector thing allows openHAB to surface the Xiaomi login flow as regular channels.
|
||||||
|
This makes it possible to scan a QR code directly from the openHAB UI, respond to captcha challenges, and handle two-factor authentication without leaving openHAB.
|
||||||
|
Stored session tokens are persisted back into the thing configuration so that subsequent binding restarts reuse the existing session automatically (TOKEN login fast-path).
|
||||||
|
|
||||||
|
### Discovery and Creation
|
||||||
|
|
||||||
|
The binding automatically proposes a Cloud Connector discovery result in the inbox on startup.
|
||||||
|
Accept it, or manually add a thing of type `miio:cloud` with the ID `cloudConnector`.
|
||||||
|
|
||||||
|
### Cloud Connector Thing Configuration
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|------------------------|---------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| username | text | false | Xiaomi account e-mail address. Only used with `PASSWORD` login method. |
|
||||||
|
| password | text | false | Xiaomi account password. Only used with `PASSWORD` login method. |
|
||||||
|
| login-method | text | false | Login method: `QRCODE` (default — scan QR code with Mi Home app) or `PASSWORD` (username + password, may require captcha / 2FA). Options: `QR Code (Default)`, `Password Login` |
|
||||||
|
| country | text | false | Regional server(s) to connect to (e.g. `de`, `sg,de`). Separate multiple values with a comma. Leave blank to try all known servers. |
|
||||||
|
| cloud-discovery-mode | text | false | Enable cloud-based device discovery. Options: `Local Only (Default)` (default), `Online Supported`, `All Devices (Advanced)`. |
|
||||||
|
| user-id | text | false | _(Advanced)_ Xiaomi user ID — retrieved automatically after successful login. Do not edit manually. |
|
||||||
|
| client-id | text | false | _(Advanced)_ Unique client identifier — generated automatically on first use. Do not change unless you know what you are doing. |
|
||||||
|
| service-token | text | false | _(Advanced)_ Xiaomi service token — retrieved automatically after successful login. Do not edit manually. |
|
||||||
|
| ssecurity | text | false | _(Advanced)_ Xiaomi ssecurity value — retrieved automatically after successful login. Do not edit manually. |
|
||||||
|
|
||||||
|
### Cloud Connector Channels
|
||||||
|
|
||||||
|
| Channel | Type | Description |
|
||||||
|
|---------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| login-image | Image | Displays the QR code to scan (QR code login) or the captcha image to solve (password login). Use the openHAB UI or a sidecar image item to view this image. |
|
||||||
|
| captcha-response | String | Send the text visible in the captcha image to this channel when a captcha is required. |
|
||||||
|
| two-fa | String | Send the 2FA / email verification code to this channel when two-factor authentication is required. |
|
||||||
|
| trigger-login | Switch | Turn `ON` to cancel any in-progress login and start a fresh login sequence (new QR code). Resets to `OFF` automatically. Has a 30-second cooldown. |
|
||||||
|
|
||||||
|
### Login Flow: QR Code (Default)
|
||||||
|
|
||||||
|
1. Add and accept the Cloud Connector thing.
|
||||||
|
1. The `login-image` channel shows an orange hourglass while the QR code is being fetched.
|
||||||
|
1. Within about one second a QR code appears in the `login-image` channel.
|
||||||
|
1. Open the **Mi Home** app → Me → tap your account photo → **Sign out** is shown if already signed in, otherwise go to **Settings → Xiaomi Account → Scan QR code**.
|
||||||
|
1. Scan the QR code. The thing status changes to ONLINE and device tokens are retrieved automatically.
|
||||||
|
1. After a successful login the session credentials are persisted into the thing configuration; subsequent openHAB restarts will reuse the session without requiring a new QR scan.
|
||||||
|
1. If the QR code expires (after ~5 minutes) before you scan it, turn the `trigger-login` channel ON to obtain a fresh QR code.
|
||||||
|
|
||||||
|
### Login Flow: Username + Password
|
||||||
|
|
||||||
|
1. Set `login-method` to `PASSWORD` and enter your `username` and `password` in the thing configuration.
|
||||||
|
1. The binding attempts to log in automatically.
|
||||||
|
1. If a **captcha** is required, the `login-image` channel shows the captcha image. Send the text you see to the `captcha-response` channel.
|
||||||
|
1. If **two-factor authentication** is required, an e-mail code is sent to your account. Send the code to the `two-fa` channel.
|
||||||
|
1. After a successful login the session credentials are persisted; subsequent restarts skip the password flow automatically.
|
||||||
|
|
||||||
|
### Migration from Binding-Level Credentials
|
||||||
|
|
||||||
|
In previous versions, cloud credentials were entered in the binding configuration page.
|
||||||
|
That mechanism is now **deprecated and ignored**.
|
||||||
|
To migrate:
|
||||||
|
|
||||||
|
1. Open the openHAB UI and accept the **Cloud Connector** discovery result from the inbox (or add a `miio:cloud` thing manually).
|
||||||
|
1. Configure the thing: set `login-method`, `username`/`password` (if using password login), and `country`.
|
||||||
|
1. Remove the old credentials from the binding configuration page (optional but recommended to avoid confusion).
|
||||||
|
1. After a successful login all devices will automatically start using the new cloud connection.
|
||||||
|
|
||||||
|
The binding also supports the discovery of devices via the cloud. This may be useful if the device is on a separate subnet.
|
||||||
|
Set `cloud-discovery-mode` on the Cloud Connector thing to `supported` or `all` to enable this.
|
||||||
|
Note: after accepting a device discovered via the cloud on a different subnet, set the device communication to `cloud` in the thing configuration.
|
||||||
|
|
||||||
## Thing Configuration
|
## Thing Configuration
|
||||||
|
|
||||||
@@ -90,7 +154,7 @@ Note: Suggest to use the cloud communication only for devices that require it.
|
|||||||
It is unknown at this time if Xiaomi has a rate limit or other limitations on the cloud usage. e.g. if having many devices would trigger some throttling from the cloud side.
|
It is unknown at this time if Xiaomi has a rate limit or other limitations on the cloud usage. e.g. if having many devices would trigger some throttling from the cloud side.
|
||||||
Note2: communications parameter is not available for lumi devices. Lumi devices communicate using the bridge/gateway.
|
Note2: communications parameter is not available for lumi devices. Lumi devices communicate using the bridge/gateway.
|
||||||
|
|
||||||
### Example Thing file
|
### Example Thing File
|
||||||
|
|
||||||
`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="326xxxx", model="philips.light.bulb", communication="direct" ]`
|
`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="326xxxx", model="philips.light.bulb", communication="direct" ]`
|
||||||
|
|
||||||
@@ -102,7 +166,7 @@ in case of gateway, instead of defining it as a Thing, use Bridge
|
|||||||
|
|
||||||
`Bridge miio:gateway:lumigateway "Mi Smarter Gateway" [ host="10.10.x.x", token="put here your token", deviceId="326xxxx", model="lumi.gateway.mieu01", communication="direct", cloudServer="de" ]`
|
`Bridge miio:gateway:lumigateway "Mi Smarter Gateway" [ host="10.10.x.x", token="put here your token", deviceId="326xxxx", model="lumi.gateway.mieu01", communication="direct", cloudServer="de" ]`
|
||||||
|
|
||||||
# Advanced: Getting unsupported devices to work with the binding
|
# Advanced: Getting Unsupported Devices to Work with the Binding
|
||||||
|
|
||||||
Newer devices may not yet be supported.
|
Newer devices may not yet be supported.
|
||||||
However, many devices share large similarities with existing devices.
|
However, many devices share large similarities with existing devices.
|
||||||
@@ -114,7 +178,7 @@ There are 3 ways to get unsupported devices working:
|
|||||||
- by switching on the `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel, this works for most newer devices. See [Create support for new devices based on online published spec database](#create-support-for-new-devices-based-on-online-published-spec-database)
|
- by switching on the `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel, this works for most newer devices. See [Create support for new devices based on online published spec database](#create-support-for-new-devices-based-on-online-published-spec-database)
|
||||||
- by switching on the `(experimental) Create channels / test properties for unsupported devices (legacy protocol)` channel. This works for older / legacy devices. It test all known properties to see which are supported by your device. See [Supported property test for unsupported devices](#supported-property-test-for-unsupported-devices)
|
- by switching on the `(experimental) Create channels / test properties for unsupported devices (legacy protocol)` channel. This works for older / legacy devices. It test all known properties to see which are supported by your device. See [Supported property test for unsupported devices](#supported-property-test-for-unsupported-devices)
|
||||||
|
|
||||||
## Substitute model for unsupported devices
|
## Substitute Model for Unsupported Devices
|
||||||
|
|
||||||
Replace the model with the model which is already supported.
|
Replace the model with the model which is already supported.
|
||||||
For this, first remove your unsupported Thing. Manually add a miio:basic Thing.
|
For this, first remove your unsupported Thing. Manually add a miio:basic Thing.
|
||||||
@@ -122,7 +186,7 @@ Besides the regular configuration (like ip address, token) the modelId needs to
|
|||||||
Normally the modelId is populated with the model of your device, however in this case, use the modelId of a similar device.
|
Normally the modelId is populated with the model of your device, however in this case, use the modelId of a similar device.
|
||||||
Look at the openHAB forum, or the openHAB GitHub repository or this readme for the modelId of similar devices.
|
Look at the openHAB forum, or the openHAB GitHub repository or this readme for the modelId of similar devices.
|
||||||
|
|
||||||
## Create support for new devices based on online published spec database
|
## Create Support for New Devices Based on Online Published Spec Database
|
||||||
|
|
||||||
The unsupported device has a `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel. When switching on, it will try to build support based on the published spec from [https://home.miot-spec.com/](https://home.miot-spec.com/).
|
The unsupported device has a `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel. When switching on, it will try to build support based on the published spec from [https://home.miot-spec.com/](https://home.miot-spec.com/).
|
||||||
It will test all properties are in the spec for your device, which may take few minutes.
|
It will test all properties are in the spec for your device, which may take few minutes.
|
||||||
@@ -133,7 +197,7 @@ If this does not happen automatically, restart the binding or restart openHAB in
|
|||||||
|
|
||||||
Please validate and feedback if all channels and actions are working, and share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
Please validate and feedback if all channels and actions are working, and share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
||||||
|
|
||||||
## Supported property test for unsupported devices
|
## Supported Property Test for Unsupported Devices
|
||||||
|
|
||||||
The unsupported device has a test channel with switch. When switching on, all known properties are tested, this may take few minutes.
|
The unsupported device has a test channel with switch. When switching on, all known properties are tested, this may take few minutes.
|
||||||
A test report will be shown in the log and is saved in the `userdata/miio` folder with a filename `test-[your model]-[timestamp].txt`.
|
A test report will be shown in the log and is saved in the `userdata/miio` folder with a filename `test-[your model]-[timestamp].txt`.
|
||||||
@@ -142,7 +206,7 @@ The Thing will go offline and will come back online as basic device, supporting
|
|||||||
The database file may need to be modified to display the right channel names.
|
The database file may need to be modified to display the right channel names.
|
||||||
After validation, please share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
After validation, please share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
||||||
|
|
||||||
## Advanced: adding local database files to support new devices
|
## Advanced: Adding Local Database Files to Support New Devices
|
||||||
|
|
||||||
Things using the basic handler (miio:basic things) are driven by json 'database' files.
|
Things using the basic handler (miio:basic things) are driven by json 'database' files.
|
||||||
This instructs the binding which channels to create, which properties and actions are associated with the channels etc.
|
This instructs the binding which channels to create, which properties and actions are associated with the channels etc.
|
||||||
@@ -153,7 +217,7 @@ If this folder is created after the start of the binding, you may need to restar
|
|||||||
Note that local database files take preference over build-in ones, hence if a json file is local and in the database the local file will be used.
|
Note that local database files take preference over build-in ones, hence if a json file is local and in the database the local file will be used.
|
||||||
For format, please check the current database files in openHAB GitHub.
|
For format, please check the current database files in openHAB GitHub.
|
||||||
|
|
||||||
# FAQ.. what to do in case of problems
|
# FAQ: What to Do in Case of Problems
|
||||||
|
|
||||||
If your device is not getting online:
|
If your device is not getting online:
|
||||||
|
|
||||||
@@ -186,17 +250,20 @@ Firmware of the device don't accept commands coming from other subnets.
|
|||||||
Set the communication in the Thing configuration to 'cloud'.
|
Set the communication in the Thing configuration to 'cloud'.
|
||||||
|
|
||||||
_Cloud connectivity is not working_
|
_Cloud connectivity is not working_
|
||||||
The most common problem is a wrong or missing userId/password. Update your Xiaomi cloud userId & password in the [miio binding configuration screen](#binding-configuration).
|
The most common problem is a wrong or missing user-id/password, or the Cloud Connector thing has not been set up yet.
|
||||||
If the problem persists you can try the following:
|
Cloud credentials are no longer configured on the binding configuration page — see the [Cloud Connector Thing](#cloud-connector-thing) section for setup instructions.
|
||||||
|
If the Cloud Connector thing is configured and still not working, try the following:
|
||||||
|
|
||||||
- Xiaomi Account verification might be needed. For some users login by the binding is unsuccessful as account verification is required, but the binding currently has no possibilities to handle this.
|
- Use the **QR code** login method instead of password login, as it avoids captcha / account-verification issues.
|
||||||
In order to pass validation your (openHAB server) ip need to be validated/confirmed.
|
- Turn the `triggerlogin` channel ON to restart the login sequence and get a fresh QR code.
|
||||||
Browse to [https://account.xiaomi.com/](https://account.xiaomi.com/) and logon to your account. Note: use the same external ip address as your openHAB server, e.g. you may need to disable your VPN.
|
- Xiaomi Account verification might be needed. For some users login by the binding is unsuccessful as account verification is required.
|
||||||
- If above is not possible or fails, You can try to find in the binding debug logging a `location url`. Try to login using this url (just after it fails) with your browser.
|
In order to pass validation your (openHAB server) ip needs to be validated/confirmed.
|
||||||
|
Browse to [https://account.xiaomi.com/](https://account.xiaomi.com/) and log on to your account. Note: use the same external IP address as your openHAB server, e.g. you may need to disable your VPN.
|
||||||
|
- If above is not possible or fails, you can try to find in the binding debug logging a `location url`. Try to login using this url (just after it fails) with your browser.
|
||||||
- Several users also reported success by resetting their Xiaomi password.
|
- Several users also reported success by resetting their Xiaomi password.
|
||||||
|
|
||||||
If it still fails, you're bit out of luck. You may try to restart openHAB (not just the binding) to clean the cookies.
|
If it still fails, try restarting openHAB (not just the binding) to clear the cookies.
|
||||||
As the cloud logon process is still little understood, your only luck might be to enable trace logging and see if you can translate the Chinese error code that it returns.
|
Enable TRACE logging on `org.openhab.binding.miio.internal.cloud` for detailed diagnostics.
|
||||||
|
|
||||||
_My Roborock vacuum is not found or not reacting_
|
_My Roborock vacuum is not found or not reacting_
|
||||||
Did you link the vacuum with the Roborock app?
|
Did you link the vacuum with the Roborock app?
|
||||||
@@ -277,11 +344,11 @@ To (re-)read the file either restart openHAB, restart the binding or alternative
|
|||||||
Note, cropping is disabled (hence showing like the maps in OH3.1 and earlier) for any `cropBorder` value < 0.
|
Note, cropping is disabled (hence showing like the maps in OH3.1 and earlier) for any `cropBorder` value < 0.
|
||||||
Note, not all the values need to be in the json file, e.g. a subset of the parameters also works, the parameters not in the `mapConfig.json` will take the default values.
|
Note, not all the values need to be in the json file, e.g. a subset of the parameters also works, the parameters not in the `mapConfig.json` will take the default values.
|
||||||
|
|
||||||
### Basic, gateway and lumi Things channels
|
### Basic, Gateway and Lumi Things Channels
|
||||||
|
|
||||||
!!!channelList
|
!!!channelList
|
||||||
|
|
||||||
## Example item file Roborock vacuum
|
## Example Item File Roborock Vacuum
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Group gVac "Xiaomi Robot Vacuum" <fan>
|
Group gVac "Xiaomi Robot Vacuum" <fan>
|
||||||
@@ -327,7 +394,7 @@ Switch lastCompleted "Last Cleaning Completed" (gVacLast) {channel="miio:vac
|
|||||||
Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"}
|
Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Basic, gateway and lumi Things item files examples
|
### Basic, Gateway and Lumi Things Item File Examples
|
||||||
|
|
||||||
!!!itemFileExamples
|
!!!itemFileExamples
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Xiaomi Wifi devices (Mi IO) Binding
|
# Xiaomi Wifi Devices (Mi IO) Binding
|
||||||
|
|
||||||
This binding is used to control Xiaomi products implementing the Mi IO protocol.
|
This binding is used to control Xiaomi products implementing the Mi IO protocol.
|
||||||
This protocol is used for most of Xiaomi Mi Ecosystem wifi devices which is branded as MiJia.
|
This protocol is used for most of Xiaomi Mi Ecosystem wifi devices which is branded as MiJia.
|
||||||
@@ -10,14 +10,15 @@ If your Xiaomi wifi device is controlled by the mihome app, most likely it commu
|
|||||||
|
|
||||||
The following things types are available:
|
The following things types are available:
|
||||||
|
|
||||||
| ThingType | Description |
|
| ThingType | Description |
|
||||||
|------------------|--------------------------------------------------------------------------------------------------------------------------|
|
|--------------------|-------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| miio:generic | Generic type for discovered devices. Once the token is available and the device model is determined, this ThingType will automatically change to the appropriate ThingType |
|
| `miio:cloud` | Cloud Connector — manages Xiaomi cloud authentication and provides device tokens to all other things. Add one per openHAB instance. |
|
||||||
| miio:vacuum | For Xiaomi/Roborock Robot Vacuum products |
|
| `miio:generic` | Generic type for discovered devices. Once the token is available and the device model is determined, this ThingType will automatically change to the appropriate ThingType |
|
||||||
| miio:basic | For most other devices like yeelights, airpurifiers. Channels and commands are determined by database configuration |
|
| `miio:vacuum` | For Xiaomi/Roborock Robot Vacuum products |
|
||||||
| miio:gateway | Similar to basic, but with the Bridge feature, it can support to forward commands for connected devices |
|
| `miio:basic` | For most other devices like yeelights, airpurifiers. Channels and commands are determined by database configuration |
|
||||||
| miio:lumi | Thing type for subdevices connected to the gateway. Note, these devices require a defined gateway to function |
|
| `miio:gateway` | Similar to basic, but with the Bridge feature, it can support to forward commands for connected devices |
|
||||||
| miio:unsupported | For experimenting with other devices which use the Mi IO protocol or to build experimental support |
|
| `miio:lumi` | Thing type for subdevices connected to the gateway. Note, these devices require a defined gateway to function |
|
||||||
|
| `miio:unsupported` | For experimenting with other devices which use the Mi IO protocol or to build experimental support |
|
||||||
|
|
||||||
# Discovery
|
# Discovery
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ Use the one of the regional servers: cn,de,i2,tw,ru,sg,us.
|
|||||||
Multiple servers can be separated with comma, or leave blank to test all known servers.
|
Multiple servers can be separated with comma, or leave blank to test all known servers.
|
||||||
See [binding configuration](#binding-configuration) for more details about the binding config.
|
See [binding configuration](#binding-configuration) for more details about the binding config.
|
||||||
|
|
||||||
## Tokens without cloud access
|
## Tokens Without Cloud Access
|
||||||
|
|
||||||
Some devices provide the token upon discovery. This may depends on the firmware version.
|
Some devices provide the token upon discovery. This may depends on the firmware version.
|
||||||
If the device does not discover your token, it needs to be retrieved from the Mi Home app.
|
If the device does not discover your token, it needs to be retrieved from the Mi Home app.
|
||||||
@@ -54,19 +55,82 @@ Note. The Xiaomi devices change the token when inclusion is done. Hence if you g
|
|||||||
|
|
||||||
## Binding Configuration
|
## Binding Configuration
|
||||||
|
|
||||||
No binding configuration is required. However to enable cloud functionality enter your Xiaomi username, password and server(s).
|
No binding configuration is required.
|
||||||
The list of the known countries and related servers is available [in the country servers](#country-servers) section.
|
Cloud functionality is provided through the dedicated **Cloud Connector** thing (`miio:cloud`) described below.
|
||||||
|
|
||||||
After successful Xiaomi cloud login, the binding will use the connection to retrieve the required device tokens from the cloud.
|
> **Note:** Entering cloud credentials directly in the binding configuration page (username / password fields) is **deprecated** and no longer has any effect.
|
||||||
For Xiaomi vacuums the map can be visualized in openHAB using the cloud connection.
|
> Please migrate to the Cloud Connector thing as described in the [Cloud Connector Thing](#cloud-connector-thing) section.
|
||||||
|
|
||||||
To enter your cloud details go to the bindings page, click the Xiaomi Mi IO binding and than configure.
|
## Cloud Connector Thing
|
||||||

|
|
||||||
|
|
||||||
In the configuration page, enter your userID /passwd and county(s) or leave the countries servers blank.
|
Cloud access — needed to retrieve device tokens and to show vacuum maps — is managed by a dedicated `miio:cloud` thing called the **Cloud Connector**.
|
||||||

|
|
||||||
|
|
||||||
The binding also supports the discovery of devices via the cloud. This may be useful if the device is on a separate subnet. (note, after accepting such a device on a different subnet, the communication needs to be set to cloud in order to have it working.)
|
### Why a Separate Thing?
|
||||||
|
|
||||||
|
The Cloud Connector thing allows openHAB to surface the Xiaomi login flow as regular channels.
|
||||||
|
This makes it possible to scan a QR code directly from the openHAB UI, respond to captcha challenges, and handle two-factor authentication without leaving openHAB.
|
||||||
|
Stored session tokens are persisted back into the thing configuration so that subsequent binding restarts reuse the existing session automatically (TOKEN login fast-path).
|
||||||
|
|
||||||
|
### Discovery and Creation
|
||||||
|
|
||||||
|
The binding automatically proposes a Cloud Connector discovery result in the inbox on startup.
|
||||||
|
Accept it, or manually add a thing of type `miio:cloud` with the ID `cloudConnector`.
|
||||||
|
|
||||||
|
### Cloud Connector Thing Configuration
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|------------------------|---------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| username | text | false | Xiaomi account e-mail address. Only used with `PASSWORD` login method. |
|
||||||
|
| password | text | false | Xiaomi account password. Only used with `PASSWORD` login method. |
|
||||||
|
| login-method | text | false | Login method: `QRCODE` (default — scan QR code with Mi Home app) or `PASSWORD` (username + password, may require captcha / 2FA). Options: `QR Code (Default)`, `Password Login` |
|
||||||
|
| country | text | false | Regional server(s) to connect to (e.g. `de`, `sg,de`). Separate multiple values with a comma. Leave blank to try all known servers. |
|
||||||
|
| cloud-discovery-mode | text | false | Enable cloud-based device discovery. Options: `Local Only (Default)` (default), `Online Supported`, `All Devices (Advanced)`. |
|
||||||
|
| user-id | text | false | _(Advanced)_ Xiaomi user ID — retrieved automatically after successful login. Do not edit manually. |
|
||||||
|
| client-id | text | false | _(Advanced)_ Unique client identifier — generated automatically on first use. Do not change unless you know what you are doing. |
|
||||||
|
| service-token | text | false | _(Advanced)_ Xiaomi service token — retrieved automatically after successful login. Do not edit manually. |
|
||||||
|
| ssecurity | text | false | _(Advanced)_ Xiaomi ssecurity value — retrieved automatically after successful login. Do not edit manually. |
|
||||||
|
|
||||||
|
### Cloud Connector Channels
|
||||||
|
|
||||||
|
| Channel | Type | Description |
|
||||||
|
|---------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| login-image | Image | Displays the QR code to scan (QR code login) or the captcha image to solve (password login). Use the openHAB UI or a sidecar image item to view this image. |
|
||||||
|
| captcha-response | String | Send the text visible in the captcha image to this channel when a captcha is required. |
|
||||||
|
| two-fa | String | Send the 2FA / email verification code to this channel when two-factor authentication is required. |
|
||||||
|
| trigger-login | Switch | Turn `ON` to cancel any in-progress login and start a fresh login sequence (new QR code). Resets to `OFF` automatically. Has a 30-second cooldown. |
|
||||||
|
|
||||||
|
### Login Flow: QR Code (Default)
|
||||||
|
|
||||||
|
1. Add and accept the Cloud Connector thing.
|
||||||
|
1. The `login-image` channel shows an orange hourglass while the QR code is being fetched.
|
||||||
|
1. Within about one second a QR code appears in the `login-image` channel.
|
||||||
|
1. Open the **Mi Home** app → Me → tap your account photo → **Sign out** is shown if already signed in, otherwise go to **Settings → Xiaomi Account → Scan QR code**.
|
||||||
|
1. Scan the QR code. The thing status changes to ONLINE and device tokens are retrieved automatically.
|
||||||
|
1. After a successful login the session credentials are persisted into the thing configuration; subsequent openHAB restarts will reuse the session without requiring a new QR scan.
|
||||||
|
1. If the QR code expires (after ~5 minutes) before you scan it, turn the `trigger-login` channel ON to obtain a fresh QR code.
|
||||||
|
|
||||||
|
### Login Flow: Username + Password
|
||||||
|
|
||||||
|
1. Set `login-method` to `PASSWORD` and enter your `username` and `password` in the thing configuration.
|
||||||
|
1. The binding attempts to log in automatically.
|
||||||
|
1. If a **captcha** is required, the `login-image` channel shows the captcha image. Send the text you see to the `captcha-response` channel.
|
||||||
|
1. If **two-factor authentication** is required, an e-mail code is sent to your account. Send the code to the `two-fa` channel.
|
||||||
|
1. After a successful login the session credentials are persisted; subsequent restarts skip the password flow automatically.
|
||||||
|
|
||||||
|
### Migration from Binding-Level Credentials
|
||||||
|
|
||||||
|
In previous versions, cloud credentials were entered in the binding configuration page.
|
||||||
|
That mechanism is now **deprecated and ignored**.
|
||||||
|
To migrate:
|
||||||
|
|
||||||
|
1. Open the openHAB UI and accept the **Cloud Connector** discovery result from the inbox (or add a `miio:cloud` thing manually).
|
||||||
|
1. Configure the thing: set `login-method`, `username`/`password` (if using password login), and `country`.
|
||||||
|
1. Remove the old credentials from the binding configuration page (optional but recommended to avoid confusion).
|
||||||
|
1. After a successful login all devices will automatically start using the new cloud connection.
|
||||||
|
|
||||||
|
The binding also supports the discovery of devices via the cloud. This may be useful if the device is on a separate subnet.
|
||||||
|
Set `cloud-discovery-mode` on the Cloud Connector thing to `supported` or `all` to enable this.
|
||||||
|
Note: after accepting a device discovered via the cloud on a different subnet, set the device communication to `cloud` in the thing configuration.
|
||||||
|
|
||||||
## Thing Configuration
|
## Thing Configuration
|
||||||
|
|
||||||
@@ -90,7 +154,7 @@ Note: Suggest to use the cloud communication only for devices that require it.
|
|||||||
It is unknown at this time if Xiaomi has a rate limit or other limitations on the cloud usage. e.g. if having many devices would trigger some throttling from the cloud side.
|
It is unknown at this time if Xiaomi has a rate limit or other limitations on the cloud usage. e.g. if having many devices would trigger some throttling from the cloud side.
|
||||||
Note2: communications parameter is not available for lumi devices. Lumi devices communicate using the bridge/gateway.
|
Note2: communications parameter is not available for lumi devices. Lumi devices communicate using the bridge/gateway.
|
||||||
|
|
||||||
### Example Thing file
|
### Example Thing File
|
||||||
|
|
||||||
`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="326xxxx", model="philips.light.bulb", communication="direct" ]`
|
`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="326xxxx", model="philips.light.bulb", communication="direct" ]`
|
||||||
|
|
||||||
@@ -102,7 +166,7 @@ in case of gateway, instead of defining it as a Thing, use Bridge
|
|||||||
|
|
||||||
`Bridge miio:gateway:lumigateway "Mi Smarter Gateway" [ host="10.10.x.x", token="put here your token", deviceId="326xxxx", model="lumi.gateway.mieu01", communication="direct", cloudServer="de" ]`
|
`Bridge miio:gateway:lumigateway "Mi Smarter Gateway" [ host="10.10.x.x", token="put here your token", deviceId="326xxxx", model="lumi.gateway.mieu01", communication="direct", cloudServer="de" ]`
|
||||||
|
|
||||||
# Advanced: Getting unsupported devices to work with the binding
|
# Advanced: Getting Unsupported Devices to Work with the Binding
|
||||||
|
|
||||||
Newer devices may not yet be supported.
|
Newer devices may not yet be supported.
|
||||||
However, many devices share large similarities with existing devices.
|
However, many devices share large similarities with existing devices.
|
||||||
@@ -114,7 +178,7 @@ There are 3 ways to get unsupported devices working:
|
|||||||
- by switching on the `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel, this works for most newer devices. See [Create support for new devices based on online published spec database](#create-support-for-new-devices-based-on-online-published-spec-database)
|
- by switching on the `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel, this works for most newer devices. See [Create support for new devices based on online published spec database](#create-support-for-new-devices-based-on-online-published-spec-database)
|
||||||
- by switching on the `(experimental) Create channels / test properties for unsupported devices (legacy protocol)` channel. This works for older / legacy devices. It test all known properties to see which are supported by your device. See [Supported property test for unsupported devices](#supported-property-test-for-unsupported-devices)
|
- by switching on the `(experimental) Create channels / test properties for unsupported devices (legacy protocol)` channel. This works for older / legacy devices. It test all known properties to see which are supported by your device. See [Supported property test for unsupported devices](#supported-property-test-for-unsupported-devices)
|
||||||
|
|
||||||
## Substitute model for unsupported devices
|
## Substitute Model for Unsupported Devices
|
||||||
|
|
||||||
Replace the model with the model which is already supported.
|
Replace the model with the model which is already supported.
|
||||||
For this, first remove your unsupported Thing. Manually add a miio:basic Thing.
|
For this, first remove your unsupported Thing. Manually add a miio:basic Thing.
|
||||||
@@ -122,7 +186,7 @@ Besides the regular configuration (like ip address, token) the modelId needs to
|
|||||||
Normally the modelId is populated with the model of your device, however in this case, use the modelId of a similar device.
|
Normally the modelId is populated with the model of your device, however in this case, use the modelId of a similar device.
|
||||||
Look at the openHAB forum, or the openHAB GitHub repository or this readme for the modelId of similar devices.
|
Look at the openHAB forum, or the openHAB GitHub repository or this readme for the modelId of similar devices.
|
||||||
|
|
||||||
## Create support for new devices based on online published spec database
|
## Create Support for New Devices Based on Online Published Spec Database
|
||||||
|
|
||||||
The unsupported device has a `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel. When switching on, it will try to build support based on the published spec from [https://home.miot-spec.com/](https://home.miot-spec.com/).
|
The unsupported device has a `(experimental) Create channels for new/unsupported devices (MIOT protocol)` channel. When switching on, it will try to build support based on the published spec from [https://home.miot-spec.com/](https://home.miot-spec.com/).
|
||||||
It will test all properties are in the spec for your device, which may take few minutes.
|
It will test all properties are in the spec for your device, which may take few minutes.
|
||||||
@@ -133,7 +197,7 @@ If this does not happen automatically, restart the binding or restart openHAB in
|
|||||||
|
|
||||||
Please validate and feedback if all channels and actions are working, and share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
Please validate and feedback if all channels and actions are working, and share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
||||||
|
|
||||||
## Supported property test for unsupported devices
|
## Supported Property Test for Unsupported Devices
|
||||||
|
|
||||||
The unsupported device has a test channel with switch. When switching on, all known properties are tested, this may take few minutes.
|
The unsupported device has a test channel with switch. When switching on, all known properties are tested, this may take few minutes.
|
||||||
A test report will be shown in the log and is saved in the `userdata/miio` folder with a filename `test-[your model]-[timestamp].txt`.
|
A test report will be shown in the log and is saved in the `userdata/miio` folder with a filename `test-[your model]-[timestamp].txt`.
|
||||||
@@ -142,7 +206,7 @@ The Thing will go offline and will come back online as basic device, supporting
|
|||||||
The database file may need to be modified to display the right channel names.
|
The database file may need to be modified to display the right channel names.
|
||||||
After validation, please share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
After validation, please share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
|
||||||
|
|
||||||
## Advanced: adding local database files to support new devices
|
## Advanced: Adding Local Database Files to Support New Devices
|
||||||
|
|
||||||
Things using the basic handler (miio:basic things) are driven by json 'database' files.
|
Things using the basic handler (miio:basic things) are driven by json 'database' files.
|
||||||
This instructs the binding which channels to create, which properties and actions are associated with the channels etc.
|
This instructs the binding which channels to create, which properties and actions are associated with the channels etc.
|
||||||
@@ -153,7 +217,7 @@ If this folder is created after the start of the binding, you may need to restar
|
|||||||
Note that local database files take preference over build-in ones, hence if a json file is local and in the database the local file will be used.
|
Note that local database files take preference over build-in ones, hence if a json file is local and in the database the local file will be used.
|
||||||
For format, please check the current database files in openHAB GitHub.
|
For format, please check the current database files in openHAB GitHub.
|
||||||
|
|
||||||
# FAQ.. what to do in case of problems
|
# FAQ: What to Do in Case of Problems
|
||||||
|
|
||||||
If your device is not getting online:
|
If your device is not getting online:
|
||||||
|
|
||||||
@@ -186,17 +250,20 @@ Firmware of the device don't accept commands coming from other subnets.
|
|||||||
Set the communication in the Thing configuration to 'cloud'.
|
Set the communication in the Thing configuration to 'cloud'.
|
||||||
|
|
||||||
_Cloud connectivity is not working_
|
_Cloud connectivity is not working_
|
||||||
The most common problem is a wrong or missing userId/password. Update your Xiaomi cloud userId & password in the [miio binding configuration screen](#binding-configuration).
|
The most common problem is a wrong or missing user-id/password, or the Cloud Connector thing has not been set up yet.
|
||||||
If the problem persists you can try the following:
|
Cloud credentials are no longer configured on the binding configuration page — see the [Cloud Connector Thing](#cloud-connector-thing) section for setup instructions.
|
||||||
|
If the Cloud Connector thing is configured and still not working, try the following:
|
||||||
|
|
||||||
- Xiaomi Account verification might be needed. For some users login by the binding is unsuccessful as account verification is required, but the binding currently has no possibilities to handle this.
|
- Use the **QR code** login method instead of password login, as it avoids captcha / account-verification issues.
|
||||||
In order to pass validation your (openHAB server) ip need to be validated/confirmed.
|
- Turn the `triggerlogin` channel ON to restart the login sequence and get a fresh QR code.
|
||||||
Browse to [https://account.xiaomi.com/](https://account.xiaomi.com/) and logon to your account. Note: use the same external ip address as your openHAB server, e.g. you may need to disable your VPN.
|
- Xiaomi Account verification might be needed. For some users login by the binding is unsuccessful as account verification is required.
|
||||||
- If above is not possible or fails, You can try to find in the binding debug logging a `location url`. Try to login using this url (just after it fails) with your browser.
|
In order to pass validation your (openHAB server) ip needs to be validated/confirmed.
|
||||||
|
Browse to [https://account.xiaomi.com/](https://account.xiaomi.com/) and log on to your account. Note: use the same external IP address as your openHAB server, e.g. you may need to disable your VPN.
|
||||||
|
- If above is not possible or fails, you can try to find in the binding debug logging a `location url`. Try to login using this url (just after it fails) with your browser.
|
||||||
- Several users also reported success by resetting their Xiaomi password.
|
- Several users also reported success by resetting their Xiaomi password.
|
||||||
|
|
||||||
If it still fails, you're bit out of luck. You may try to restart openHAB (not just the binding) to clean the cookies.
|
If it still fails, try restarting openHAB (not just the binding) to clear the cookies.
|
||||||
As the cloud logon process is still little understood, your only luck might be to enable trace logging and see if you can translate the Chinese error code that it returns.
|
Enable TRACE logging on `org.openhab.binding.miio.internal.cloud` for detailed diagnostics.
|
||||||
|
|
||||||
_My Roborock vacuum is not found or not reacting_
|
_My Roborock vacuum is not found or not reacting_
|
||||||
Did you link the vacuum with the Roborock app?
|
Did you link the vacuum with the Roborock app?
|
||||||
@@ -679,7 +746,7 @@ To (re-)read the file either restart openHAB, restart the binding or alternative
|
|||||||
Note, cropping is disabled (hence showing like the maps in OH3.1 and earlier) for any `cropBorder` value < 0.
|
Note, cropping is disabled (hence showing like the maps in OH3.1 and earlier) for any `cropBorder` value < 0.
|
||||||
Note, not all the values need to be in the json file, e.g. a subset of the parameters also works, the parameters not in the `mapConfig.json` will take the default values.
|
Note, not all the values need to be in the json file, e.g. a subset of the parameters also works, the parameters not in the `mapConfig.json` will take the default values.
|
||||||
|
|
||||||
### Basic, gateway and lumi Things channels
|
### Basic, Gateway and Lumi Things Channels
|
||||||
|
|
||||||
### Mi Air Frying Pan (<a name="careli-fryer-maf01">careli.fryer.maf01</a>) Channels
|
### Mi Air Frying Pan (<a name="careli-fryer-maf01">careli.fryer.maf01</a>) Channels
|
||||||
|
|
||||||
@@ -6473,7 +6540,7 @@ Note, not all the values need to be in the json file, e.g. a subset of the param
|
|||||||
| lp_autooff_delay | Number | Low Power Limit Time | |
|
| lp_autooff_delay | Number | Low Power Limit Time | |
|
||||||
| lp_threshold | Number | Low Power Threshold | |
|
| lp_threshold | Number | Low Power Threshold | |
|
||||||
|
|
||||||
## Example item file Roborock vacuum
|
## Example Item File Roborock Vacuum
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Group gVac "Xiaomi Robot Vacuum" <fan>
|
Group gVac "Xiaomi Robot Vacuum" <fan>
|
||||||
@@ -6519,7 +6586,7 @@ Switch lastCompleted "Last Cleaning Completed" (gVacLast) {channel="miio:vac
|
|||||||
Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"}
|
Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Basic, gateway and lumi Things item files examples
|
### Basic, Gateway and Lumi Things Item File Examples
|
||||||
|
|
||||||
### Mi Air Frying Pan (careli.fryer.maf01) item file lines
|
### Mi Air Frying Pan (careli.fryer.maf01) item file lines
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -40,10 +40,11 @@ public final class MiIoBindingConstants {
|
|||||||
public static final ThingTypeUID THING_TYPE_GATEWAY = new ThingTypeUID(BINDING_ID, "gateway");
|
public static final ThingTypeUID THING_TYPE_GATEWAY = new ThingTypeUID(BINDING_ID, "gateway");
|
||||||
public static final ThingTypeUID THING_TYPE_VACUUM = new ThingTypeUID(BINDING_ID, "vacuum");
|
public static final ThingTypeUID THING_TYPE_VACUUM = new ThingTypeUID(BINDING_ID, "vacuum");
|
||||||
public static final ThingTypeUID THING_TYPE_UNSUPPORTED = new ThingTypeUID(BINDING_ID, "unsupported");
|
public static final ThingTypeUID THING_TYPE_UNSUPPORTED = new ThingTypeUID(BINDING_ID, "unsupported");
|
||||||
|
public static final ThingTypeUID THING_TYPE_CLOUD = new ThingTypeUID(BINDING_ID, "cloud");
|
||||||
|
|
||||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
|
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
|
||||||
.unmodifiableSet(Stream.of(THING_TYPE_MIIO, THING_TYPE_BASIC, THING_TYPE_LUMI, THING_TYPE_GATEWAY,
|
.unmodifiableSet(Stream.of(THING_TYPE_MIIO, THING_TYPE_BASIC, THING_TYPE_LUMI, THING_TYPE_GATEWAY,
|
||||||
THING_TYPE_VACUUM, THING_TYPE_UNSUPPORTED).collect(Collectors.toSet()));
|
THING_TYPE_VACUUM, THING_TYPE_UNSUPPORTED, THING_TYPE_CLOUD).collect(Collectors.toSet()));
|
||||||
|
|
||||||
public static final Set<ThingTypeUID> NONGENERIC_THING_TYPES_UIDS = Collections.unmodifiableSet(
|
public static final Set<ThingTypeUID> NONGENERIC_THING_TYPES_UIDS = Collections.unmodifiableSet(
|
||||||
Stream.of(THING_TYPE_BASIC, THING_TYPE_LUMI, THING_TYPE_GATEWAY, THING_TYPE_VACUUM, THING_TYPE_UNSUPPORTED)
|
Stream.of(THING_TYPE_BASIC, THING_TYPE_LUMI, THING_TYPE_GATEWAY, THING_TYPE_VACUUM, THING_TYPE_UNSUPPORTED)
|
||||||
@@ -109,6 +110,11 @@ public final class MiIoBindingConstants {
|
|||||||
public static final String CHANNEL_HISTORY_RECORD = "cleaning#last_clean_record";
|
public static final String CHANNEL_HISTORY_RECORD = "cleaning#last_clean_record";
|
||||||
public static final String CHANNEL_VACUUM_MAP = "cleaning#map";
|
public static final String CHANNEL_VACUUM_MAP = "cleaning#map";
|
||||||
|
|
||||||
|
public static final String CHANNEL_LOGIN_IMAGE = "login-image";
|
||||||
|
public static final String CHANNEL_CAPTCHA_RESPONSE = "captcha-response";
|
||||||
|
public static final String CHANNEL_TWOFA = "two-fa";
|
||||||
|
public static final String CHANNEL_TRIGGER_LOGIN = "trigger-login";
|
||||||
|
|
||||||
public static final String PROPERTY_HOST_IP = "host";
|
public static final String PROPERTY_HOST_IP = "host";
|
||||||
public static final String PROPERTY_DID = "deviceId";
|
public static final String PROPERTY_DID = "deviceId";
|
||||||
public static final String PROPERTY_TOKEN = "token";
|
public static final String PROPERTY_TOKEN = "token";
|
||||||
@@ -117,6 +123,16 @@ public final class MiIoBindingConstants {
|
|||||||
public static final String PROPERTY_TIMEOUT = "timeout";
|
public static final String PROPERTY_TIMEOUT = "timeout";
|
||||||
public static final String PROPERTY_CLOUDSERVER = "cloudServer";
|
public static final String PROPERTY_CLOUDSERVER = "cloudServer";
|
||||||
|
|
||||||
|
public static final String CONFIG_USERNAME = "username";
|
||||||
|
public static final String CONFIG_PASSWORD = "password";
|
||||||
|
public static final String CONFIG_COUNTRY = "country";
|
||||||
|
public static final String CONFIG_CLIENT_ID = "client-id";
|
||||||
|
public static final String CONFIG_USER_ID = "user-id";
|
||||||
|
public static final String CONFIG_SERVICE_TOKEN = "service-token";
|
||||||
|
public static final String CONFIG_SSECURITY = "ssecurity";
|
||||||
|
public static final String CONFIG_LOGIN_METHOD = "login-method";
|
||||||
|
public static final String CONFIG_CLOUD_DISCOVERY_MODE = "cloud-discovery-mode";
|
||||||
|
|
||||||
public static final Set<String> PERSISTENT_CHANNELS = Collections.unmodifiableSet(
|
public static final Set<String> PERSISTENT_CHANNELS = Collections.unmodifiableSet(
|
||||||
Stream.of(CHANNEL_COMMAND, CHANNEL_RPC, CHANNEL_SSID, CHANNEL_BSSID, CHANNEL_RSSI, CHANNEL_LIFE)
|
Stream.of(CHANNEL_COMMAND, CHANNEL_RPC, CHANNEL_SSID, CHANNEL_BSSID, CHANNEL_RSSI, CHANNEL_LIFE)
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|||||||
+16
-7
@@ -12,7 +12,13 @@
|
|||||||
*/
|
*/
|
||||||
package org.openhab.binding.miio.internal;
|
package org.openhab.binding.miio.internal;
|
||||||
|
|
||||||
import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.SUPPORTED_THING_TYPES_UIDS;
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.THING_TYPE_BASIC;
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.THING_TYPE_CLOUD;
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.THING_TYPE_GATEWAY;
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.THING_TYPE_LUMI;
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.THING_TYPE_MIIO;
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.THING_TYPE_VACUUM;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
@@ -25,6 +31,7 @@ import org.openhab.binding.miio.internal.basic.BasicChannelTypeProvider;
|
|||||||
import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
|
import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
|
||||||
import org.openhab.binding.miio.internal.cloud.CloudConnector;
|
import org.openhab.binding.miio.internal.cloud.CloudConnector;
|
||||||
import org.openhab.binding.miio.internal.handler.MiIoBasicHandler;
|
import org.openhab.binding.miio.internal.handler.MiIoBasicHandler;
|
||||||
|
import org.openhab.binding.miio.internal.handler.MiIoCloudThingHandler;
|
||||||
import org.openhab.binding.miio.internal.handler.MiIoGatewayHandler;
|
import org.openhab.binding.miio.internal.handler.MiIoGatewayHandler;
|
||||||
import org.openhab.binding.miio.internal.handler.MiIoGenericHandler;
|
import org.openhab.binding.miio.internal.handler.MiIoGenericHandler;
|
||||||
import org.openhab.binding.miio.internal.handler.MiIoLumiHandler;
|
import org.openhab.binding.miio.internal.handler.MiIoLumiHandler;
|
||||||
@@ -83,13 +90,12 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory {
|
|||||||
this.i18nProvider = i18nProvider;
|
this.i18nProvider = i18nProvider;
|
||||||
this.localeProvider = localeProvider;
|
this.localeProvider = localeProvider;
|
||||||
this.cloudConnector = cloudConnector;
|
this.cloudConnector = cloudConnector;
|
||||||
@Nullable
|
|
||||||
String username = (String) properties.get("username");
|
String username = (String) properties.get("username");
|
||||||
@Nullable
|
if (username != null && !username.trim().isEmpty()) {
|
||||||
String password = (String) properties.get("password");
|
logger.info("Xiaomi Mi IO binding: Cloud credentials found in the binding-level configuration. "
|
||||||
@Nullable
|
+ "This is deprecated and the credentials will be ignored. "
|
||||||
String country = (String) properties.get("country");
|
+ "Please create or discover a 'Cloud Connector' (miio:cloud) thing to manage Xiaomi cloud credentials.");
|
||||||
cloudConnector.setCredentials(username, password, country);
|
}
|
||||||
try {
|
try {
|
||||||
if (!scheduler.isShutdown()) {
|
if (!scheduler.isShutdown()) {
|
||||||
scheduledTask = scheduler.submit(() -> cloudConnector.isConnected(true));
|
scheduledTask = scheduler.submit(() -> cloudConnector.isConnected(true));
|
||||||
@@ -137,6 +143,9 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory {
|
|||||||
return new MiIoVacuumHandler(thing, miIoDatabaseWatchService, cloudConnector, channelTypeRegistry,
|
return new MiIoVacuumHandler(thing, miIoDatabaseWatchService, cloudConnector, channelTypeRegistry,
|
||||||
i18nProvider, localeProvider);
|
i18nProvider, localeProvider);
|
||||||
}
|
}
|
||||||
|
if (thingTypeUID.equals(THING_TYPE_CLOUD)) {
|
||||||
|
return new MiIoCloudThingHandler(thing, cloudConnector);
|
||||||
|
}
|
||||||
return new MiIoUnsupportedHandler(thing, miIoDatabaseWatchService, cloudConnector,
|
return new MiIoUnsupportedHandler(thing, miIoDatabaseWatchService, cloudConnector,
|
||||||
httpClientFactory.getCommonHttpClient(), i18nProvider, localeProvider);
|
httpClientFactory.getCommonHttpClient(), i18nProvider, localeProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -90,10 +90,10 @@ public final class Utils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String obfuscateToken(String tokenString) {
|
public static String obfuscateToken(String tokenString) {
|
||||||
if (tokenString.length() > 8) {
|
if (tokenString.length() > 4) {
|
||||||
return tokenString.substring(0, 8)
|
return tokenString.substring(0, 4)
|
||||||
.concat((tokenString.length() < 24) ? tokenString.substring(8).replaceAll(".", "X")
|
.concat((tokenString.length() < 24) ? tokenString.substring(4).replaceAll(".", "*")
|
||||||
: tokenString.substring(8, 24).replaceAll(".", "X").concat(tokenString.substring(24)));
|
: tokenString.substring(4, 24).replaceAll(".", "*").concat(tokenString.substring(24)));
|
||||||
} else {
|
} else {
|
||||||
return tokenString;
|
return tokenString;
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-19
@@ -17,14 +17,16 @@ import static org.openhab.binding.miio.internal.MiIoBindingConstants.BINDING_ID;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map.Entry;
|
import java.util.Map.Entry;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import org.eclipse.jdt.annotation.NonNull;
|
|
||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
import org.eclipse.jetty.client.HttpClient;
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
import org.openhab.binding.miio.internal.MiIoSendCommand;
|
import org.openhab.binding.miio.internal.MiIoSendCommand;
|
||||||
|
import org.openhab.binding.miio.internal.cloud.MiCloudConnector.CloudLoginMode;
|
||||||
import org.openhab.core.cache.ExpiringCache;
|
import org.openhab.core.cache.ExpiringCache;
|
||||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||||
import org.openhab.core.io.net.http.HttpUtil;
|
import org.openhab.core.io.net.http.HttpUtil;
|
||||||
@@ -66,17 +68,25 @@ public class CloudConnector {
|
|||||||
private String username = "";
|
private String username = "";
|
||||||
private String password = "";
|
private String password = "";
|
||||||
private String country = "ru,us,tw,sg,cn,de,i2";
|
private String country = "ru,us,tw,sg,cn,de,i2";
|
||||||
|
private @Nullable String userId;
|
||||||
|
private @Nullable String clientId;
|
||||||
|
private @Nullable String ssecurity;
|
||||||
|
private @Nullable String serviceToken;
|
||||||
|
private CloudLoginMode loginMode = CloudLoginMode.QRCODE;
|
||||||
|
private String cloudDiscoveryMode = "disabled";
|
||||||
|
|
||||||
private List<CloudDeviceDTO> deviceList = new ArrayList<>();
|
private List<CloudDeviceDTO> deviceList = new ArrayList<>();
|
||||||
private boolean connected;
|
private boolean connected;
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
private @Nullable MiCloudConnector cloudConnector;
|
private @Nullable MiCloudConnector cloudConnector;
|
||||||
private final Logger logger = LoggerFactory.getLogger(CloudConnector.class);
|
private final Logger logger = LoggerFactory.getLogger(CloudConnector.class);
|
||||||
|
private final List<CloudLoginListener> pendingListeners = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
private ConcurrentHashMap<@NonNull String, @NonNull HomeListDTO> homeLists = new ConcurrentHashMap<>();
|
private ConcurrentHashMap<String, HomeListDTO> homeLists = new ConcurrentHashMap<>();
|
||||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
private ExpiringCache<Boolean> logonCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
|
private ExpiringCache<Boolean> loginCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
|
||||||
return logon();
|
return login();
|
||||||
});
|
});
|
||||||
|
|
||||||
private ExpiringCache<String> refreshDeviceList = new ExpiringCache<>(CACHE_EXPIRY, () -> {
|
private ExpiringCache<String> refreshDeviceList = new ExpiringCache<>(CACHE_EXPIRY, () -> {
|
||||||
@@ -156,15 +166,34 @@ public class CloudConnector {
|
|||||||
return isConnected(false);
|
return isConnected(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void registerListener(CloudLoginListener cloudLoginListener) {
|
||||||
|
final MiCloudConnector cl = cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.registerListener(cloudLoginListener);
|
||||||
|
} else {
|
||||||
|
if (!pendingListeners.contains(cloudLoginListener)) {
|
||||||
|
pendingListeners.add(cloudLoginListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unregisterListener(CloudLoginListener cloudLoginListener) {
|
||||||
|
pendingListeners.remove(cloudLoginListener);
|
||||||
|
final MiCloudConnector cl = cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.unregisterListener(cloudLoginListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isConnected(boolean force) {
|
public boolean isConnected(boolean force) {
|
||||||
final MiCloudConnector cl = cloudConnector;
|
final MiCloudConnector cl = cloudConnector;
|
||||||
if (cl != null && cl.hasLoginToken()) {
|
if (cl != null && cl.hasLoginToken()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (force) {
|
if (force) {
|
||||||
logonCache.invalidateValue();
|
loginCache.invalidateValue();
|
||||||
}
|
}
|
||||||
final @Nullable Boolean c = logonCache.getValue();
|
final @Nullable Boolean c = loginCache.getValue();
|
||||||
if (c != null && c.booleanValue()) {
|
if (c != null && c.booleanValue()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -205,8 +234,8 @@ public class CloudConnector {
|
|||||||
|
|
||||||
public @Nullable RawType getMap(String mapId, String country) throws MiCloudException {
|
public @Nullable RawType getMap(String mapId, String country) throws MiCloudException {
|
||||||
logger.debug("Getting vacuum map {} from Xiaomi cloud server: '{}'", mapId, country);
|
logger.debug("Getting vacuum map {} from Xiaomi cloud server: '{}'", mapId, country);
|
||||||
String mapCountry;
|
String mapCountry = "";
|
||||||
String mapUrl = "";
|
Optional<String> mapUrl = Optional.empty();
|
||||||
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
if (cl == null || !isConnected()) {
|
if (cl == null || !isConnected()) {
|
||||||
throw new MiCloudException("Cannot execute request. Cloud service not available");
|
throw new MiCloudException("Cannot execute request. Cloud service not available");
|
||||||
@@ -214,9 +243,14 @@ public class CloudConnector {
|
|||||||
if (country.isEmpty()) {
|
if (country.isEmpty()) {
|
||||||
logger.debug("Server not defined in thing. Trying servers: {}", this.country);
|
logger.debug("Server not defined in thing. Trying servers: {}", this.country);
|
||||||
for (String mapCountryServer : this.country.split(",")) {
|
for (String mapCountryServer : this.country.split(",")) {
|
||||||
mapCountry = mapCountryServer.trim().toLowerCase();
|
try {
|
||||||
mapUrl = cl.getMapUrl(mapId, mapCountry);
|
mapCountry = mapCountryServer.trim().toLowerCase();
|
||||||
logger.debug("Map download from server {} returned {}", mapCountry, mapUrl);
|
mapUrl = cl.getMapUrl(mapId, mapCountry);
|
||||||
|
logger.debug("Map download from server {} returned {}", mapCountry, mapUrl);
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
logger.debug("Failed to get map from server '{}': {}", mapCountry, e.getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!mapUrl.isEmpty()) {
|
if (!mapUrl.isEmpty()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -225,12 +259,12 @@ public class CloudConnector {
|
|||||||
mapCountry = country.trim().toLowerCase();
|
mapCountry = country.trim().toLowerCase();
|
||||||
mapUrl = cl.getMapUrl(mapId, mapCountry);
|
mapUrl = cl.getMapUrl(mapId, mapCountry);
|
||||||
}
|
}
|
||||||
if (mapUrl.isBlank()) {
|
if (!mapUrl.isPresent() || mapUrl.get().isEmpty()) {
|
||||||
logger.debug("Cannot download map data: Returned map URL is empty");
|
logger.debug("Cannot download map data: Returned map URL is empty");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
RawType mapData = HttpUtil.downloadData(mapUrl, null, false, -1);
|
RawType mapData = HttpUtil.downloadData(mapUrl.get(), null, false, -1);
|
||||||
if (mapData != null) {
|
if (mapData != null) {
|
||||||
return mapData;
|
return mapData;
|
||||||
} else {
|
} else {
|
||||||
@@ -253,21 +287,74 @@ public class CloudConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean logon() {
|
public void setCredentials(@Nullable String username, @Nullable String password, @Nullable String country,
|
||||||
if (username.isEmpty() || password.isEmpty()) {
|
@Nullable String clientId, @Nullable String userId, @Nullable String serviceToken,
|
||||||
logger.debug("No Xiaomi cloud credentials. Cloud connectivity disabled");
|
@Nullable String ssecurity) {
|
||||||
logger.debug("Logon details: username: '{}', pass: '{}', country: '{}'", username,
|
setCredentials(username, password, country);
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.serviceToken = serviceToken;
|
||||||
|
this.ssecurity = ssecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean login() {
|
||||||
|
if (loginMode == CloudLoginMode.PASSWORD && (username.isEmpty() || password.isEmpty())) {
|
||||||
|
logger.debug("No Xiaomi cloud credentials. Cloud connectivity disabled for PASSWORD mode");
|
||||||
|
logger.debug("Login details: username: '{}', pass: '{}', country: '{}'", username,
|
||||||
password.replaceAll(".", "*"), country);
|
password.replaceAll(".", "*"), country);
|
||||||
return connected;
|
return connected;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final MiCloudConnector cl = new MiCloudConnector(username, password, httpClient);
|
logger.debug("Xiaomi cloud login mode is {}", this.loginMode);
|
||||||
|
|
||||||
|
final MiCloudConnector cl;
|
||||||
|
switch (this.loginMode) {
|
||||||
|
case TOKEN:
|
||||||
|
cl = new MiCloudConnector(username, password, httpClient, this.clientId, this.userId,
|
||||||
|
this.serviceToken, this.ssecurity);
|
||||||
|
break;
|
||||||
|
case PASSWORD:
|
||||||
|
cl = new MiCloudUserIdLoginConnector(username, password, httpClient, this.clientId, this.userId,
|
||||||
|
this.serviceToken, this.ssecurity);
|
||||||
|
break;
|
||||||
|
case QRCODE:
|
||||||
|
default:
|
||||||
|
cl = new MiCloudQRConnector(username, password, httpClient, this.clientId, this.userId,
|
||||||
|
this.serviceToken, this.ssecurity);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer any listeners registered before the connector was created
|
||||||
|
for (CloudLoginListener listener : pendingListeners) {
|
||||||
|
cl.registerListener(listener);
|
||||||
|
}
|
||||||
|
pendingListeners.clear();
|
||||||
|
|
||||||
|
// Also re-register listeners from any previous connector instance
|
||||||
|
final MiCloudConnector prev = this.cloudConnector;
|
||||||
|
if (prev != null) {
|
||||||
|
for (CloudLoginListener listener : prev.getListeners()) {
|
||||||
|
cl.registerListener(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.cloudConnector = cl;
|
this.cloudConnector = cl;
|
||||||
connected = cl.login();
|
if (loginMode != CloudLoginMode.TOKEN && cl.getListeners().isEmpty()) {
|
||||||
|
logger.debug("No listeners registered. Skipping {} login flow.", loginMode);
|
||||||
|
connected = false;
|
||||||
|
} else {
|
||||||
|
connected = cl.login();
|
||||||
|
}
|
||||||
if (connected) {
|
if (connected) {
|
||||||
|
// Sync back the potentially refreshed token fields
|
||||||
|
this.serviceToken = cl.getServiceToken();
|
||||||
|
this.userId = cl.getUserId();
|
||||||
|
this.ssecurity = cl.getSsecurity();
|
||||||
getDevicesList();
|
getDevicesList();
|
||||||
} else {
|
} else {
|
||||||
deviceListState = CloudListState.FAILED;
|
deviceListState = CloudListState.FAILED;
|
||||||
|
// Clear stale token so next login() attempt doesn't reuse it
|
||||||
|
this.serviceToken = "";
|
||||||
}
|
}
|
||||||
} catch (MiCloudException e) {
|
} catch (MiCloudException e) {
|
||||||
connected = false;
|
connected = false;
|
||||||
@@ -364,4 +451,105 @@ public class CloudConnector {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasLoginToken() {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
return cl == null ? false : cl.hasLoginToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserId() {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
return cl == null ? "" : cl.getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(String userId) {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.setUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServiceToken() {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
return cl == null ? "" : cl.getServiceToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServiceToken(String serviceToken) {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.setServiceToken(serviceToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSsecurity() {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
return cl == null ? "" : cl.getSsecurity();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSsecurity(String ssecurity) {
|
||||||
|
final @Nullable MiCloudConnector cl = this.cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.setSsecurity(ssecurity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginMode(CloudLoginMode loginMode) {
|
||||||
|
this.loginMode = loginMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCloudDiscoveryMode(String cloudDiscoveryMode) {
|
||||||
|
this.cloudDiscoveryMode = cloudDiscoveryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCloudDiscoveryMode() {
|
||||||
|
return cloudDiscoveryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the active cloud connector and invalidates the login cache, allowing a fresh
|
||||||
|
* login sequence to be initiated on the next {@link #isConnected(boolean)} call.
|
||||||
|
*/
|
||||||
|
public void resetLogin() {
|
||||||
|
final MiCloudConnector cl = cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
// Preserve listeners so they are re-registered when the new connector is created in login()
|
||||||
|
for (CloudLoginListener listener : cl.getListeners()) {
|
||||||
|
if (!pendingListeners.contains(listener)) {
|
||||||
|
pendingListeners.add(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cl.stopClient();
|
||||||
|
}
|
||||||
|
cloudConnector = null;
|
||||||
|
connected = false;
|
||||||
|
loginCache.invalidateValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits a captcha response to the active cloud connector's login flow.
|
||||||
|
*
|
||||||
|
* @param captchaResponse the captcha text entered by the user
|
||||||
|
*/
|
||||||
|
public void submitCaptcha(String captchaResponse) {
|
||||||
|
final MiCloudConnector cl = cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.login(captchaResponse);
|
||||||
|
} else {
|
||||||
|
logger.debug("submitCaptcha: no active cloud connector");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits a 2FA response code to the active cloud connector's login flow.
|
||||||
|
*
|
||||||
|
* @param faCode the 2FA code entered by the user
|
||||||
|
*/
|
||||||
|
public void submit2FA(String faCode) {
|
||||||
|
final MiCloudConnector cl = cloudConnector;
|
||||||
|
if (cl != null) {
|
||||||
|
cl.faResponse(faCode);
|
||||||
|
} else {
|
||||||
|
logger.debug("submit2FA: no active cloud connector");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -12,11 +12,14 @@
|
|||||||
*/
|
*/
|
||||||
package org.openhab.binding.miio.internal.cloud;
|
package org.openhab.binding.miio.internal.cloud;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
@@ -65,4 +68,58 @@ public class CloudCrypto {
|
|||||||
throw new MiIoCryptoException(e.getMessage(), e);
|
throw new MiIoCryptoException(e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a payload using RC4, discarding the first 1024 bytes of the keystream.
|
||||||
|
*
|
||||||
|
* @param password The Base64 encoded key.
|
||||||
|
* @param payload The string payload to encrypt.
|
||||||
|
* @return The Base64 encoded encrypted payload.
|
||||||
|
* @throws MiIoCryptoException if an error occurs during encryption.
|
||||||
|
*/
|
||||||
|
public static String encryptRc4(String password, String payload) throws MiIoCryptoException {
|
||||||
|
try {
|
||||||
|
byte[] key = Base64.getDecoder().decode(password);
|
||||||
|
SecretKeySpec secretKeySpec = new SecretKeySpec(key, "RC4");
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance("RC4");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
|
||||||
|
|
||||||
|
// Discard the first 1024 bytes of the keystream
|
||||||
|
byte[] discard = new byte[1024];
|
||||||
|
cipher.update(discard);
|
||||||
|
|
||||||
|
byte[] encryptedPayload = cipher.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(encryptedPayload);
|
||||||
|
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||||
|
throw new MiIoCryptoException(e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts an RC4 encrypted payload, discarding the first 1024 bytes of the keystream.
|
||||||
|
*
|
||||||
|
* @param password The Base64 encoded key.
|
||||||
|
* @param payload The Base64 encoded encrypted payload.
|
||||||
|
* @return The decrypted payload as a byte array.
|
||||||
|
* @throws MiIoCryptoException if an error occurs during decryption.
|
||||||
|
*/
|
||||||
|
public static byte[] decryptRc4(String password, String payload) throws MiIoCryptoException {
|
||||||
|
try {
|
||||||
|
byte[] key = Base64.getDecoder().decode(password);
|
||||||
|
SecretKeySpec secretKeySpec = new SecretKeySpec(key, "RC4");
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance("RC4");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec);
|
||||||
|
|
||||||
|
// Discard the first 1024 bytes of the keystream
|
||||||
|
byte[] discard = new byte[1024];
|
||||||
|
cipher.update(discard);
|
||||||
|
|
||||||
|
byte[] decodedPayload = Base64.getDecoder().decode(payload);
|
||||||
|
return cipher.doFinal(decodedPayload);
|
||||||
|
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||||
|
throw new MiIoCryptoException(e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-28
@@ -12,6 +12,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.openhab.binding.miio.internal.cloud;
|
package org.openhab.binding.miio.internal.cloud;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
|
||||||
import com.google.gson.annotations.Expose;
|
import com.google.gson.annotations.Expose;
|
||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
@@ -20,103 +25,104 @@ import com.google.gson.annotations.SerializedName;
|
|||||||
*
|
*
|
||||||
* @author Marcel Verpaalen - Initial contribution
|
* @author Marcel Verpaalen - Initial contribution
|
||||||
*/
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
public class CloudLogin1DTO {
|
public class CloudLogin1DTO {
|
||||||
@SerializedName("serviceParam")
|
@SerializedName("serviceParam")
|
||||||
@Expose
|
@Expose
|
||||||
private String serviceParam;
|
private @Nullable String serviceParam;
|
||||||
@SerializedName("qs")
|
@SerializedName("qs")
|
||||||
@Expose
|
@Expose
|
||||||
private String qs;
|
private @Nullable String qs;
|
||||||
@SerializedName("code")
|
@SerializedName("code")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer code;
|
private @Nullable Integer code;
|
||||||
@SerializedName("description")
|
@SerializedName("description")
|
||||||
@Expose
|
@Expose
|
||||||
private String description;
|
private @Nullable String description;
|
||||||
@SerializedName("securityStatus")
|
@SerializedName("securityStatus")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer securityStatus;
|
private @Nullable Integer securityStatus;
|
||||||
@SerializedName("_sign")
|
@SerializedName("_sign")
|
||||||
@Expose
|
@Expose
|
||||||
private String sign;
|
private @Nullable String sign;
|
||||||
@SerializedName("sid")
|
@SerializedName("sid")
|
||||||
@Expose
|
@Expose
|
||||||
private String sid;
|
private @Nullable String sid;
|
||||||
@SerializedName("result")
|
@SerializedName("result")
|
||||||
@Expose
|
@Expose
|
||||||
private String result;
|
private @Nullable String result;
|
||||||
@SerializedName("captchaUrl")
|
@SerializedName("captchaUrl")
|
||||||
@Expose
|
@Expose
|
||||||
private String captchaUrl;
|
private @Nullable String captchaUrl;
|
||||||
@SerializedName("callback")
|
@SerializedName("callback")
|
||||||
@Expose
|
@Expose
|
||||||
private String callback;
|
private @Nullable String callback;
|
||||||
@SerializedName("location")
|
@SerializedName("location")
|
||||||
@Expose
|
@Expose
|
||||||
private String location;
|
private @Nullable String location;
|
||||||
@SerializedName("pwd")
|
@SerializedName("pwd")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer pwd;
|
private @Nullable Integer pwd;
|
||||||
@SerializedName("child")
|
@SerializedName("child")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer child;
|
private @Nullable Integer child;
|
||||||
@SerializedName("desc")
|
@SerializedName("desc")
|
||||||
@Expose
|
@Expose
|
||||||
private String desc;
|
private @Nullable String desc;
|
||||||
|
|
||||||
public String getServiceParam() {
|
public String getServiceParam() {
|
||||||
return serviceParam;
|
return Objects.requireNonNullElse(serviceParam, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getQs() {
|
public String getQs() {
|
||||||
return qs;
|
return Objects.requireNonNullElse(qs, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getCode() {
|
public Integer getCode() {
|
||||||
return code;
|
return Objects.requireNonNullElse(code, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return Objects.requireNonNullElse(description, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getSecurityStatus() {
|
public Integer getSecurityStatus() {
|
||||||
return securityStatus;
|
return Objects.requireNonNullElse(securityStatus, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getSign() {
|
public String getSign() {
|
||||||
return sign;
|
return Objects.requireNonNullElse(sign, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getSid() {
|
public String getSid() {
|
||||||
return sid;
|
return Objects.requireNonNullElse(sid, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getResult() {
|
public String getResult() {
|
||||||
return result;
|
return Objects.requireNonNullElse(result, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCaptchaUrl() {
|
public String getCaptchaUrl() {
|
||||||
return captchaUrl;
|
return Objects.requireNonNullElse(captchaUrl, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCallback() {
|
public String getCallback() {
|
||||||
return callback;
|
return Objects.requireNonNullElse(callback, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getLocation() {
|
public String getLocation() {
|
||||||
return location;
|
return Objects.requireNonNullElse(location, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getPwd() {
|
public Integer getPwd() {
|
||||||
return pwd;
|
return Objects.requireNonNullElse(pwd, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getChild() {
|
public Integer getChild() {
|
||||||
return child;
|
return Objects.requireNonNullElse(child, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDesc() {
|
public String getDesc() {
|
||||||
return desc;
|
return Objects.requireNonNullElse(desc, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-49
@@ -12,6 +12,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.openhab.binding.miio.internal.cloud;
|
package org.openhab.binding.miio.internal.cloud;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
|
||||||
import com.google.gson.annotations.Expose;
|
import com.google.gson.annotations.Expose;
|
||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
@@ -20,105 +25,112 @@ import com.google.gson.annotations.SerializedName;
|
|||||||
*
|
*
|
||||||
* @author Marcel Verpaalen - Initial contribution
|
* @author Marcel Verpaalen - Initial contribution
|
||||||
*/
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
public class CloudLoginDTO {
|
public class CloudLoginDTO {
|
||||||
|
|
||||||
@SerializedName("qs")
|
@SerializedName("qs")
|
||||||
@Expose
|
@Expose
|
||||||
private String qs;
|
private @Nullable String qs;
|
||||||
@SerializedName("psecurity")
|
@SerializedName("psecurity")
|
||||||
@Expose
|
@Expose
|
||||||
private String psecurity;
|
private @Nullable String psecurity;
|
||||||
@SerializedName("nonce")
|
@SerializedName("nonce")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer nonce;
|
private @Nullable Integer nonce;
|
||||||
@SerializedName("ssecurity")
|
@SerializedName("ssecurity")
|
||||||
@Expose
|
@Expose
|
||||||
private String ssecurity;
|
private @Nullable String ssecurity;
|
||||||
@SerializedName("passToken")
|
@SerializedName("passToken")
|
||||||
@Expose
|
@Expose
|
||||||
private String passToken;
|
private @Nullable String passToken;
|
||||||
@SerializedName("userId")
|
@SerializedName("userId")
|
||||||
@Expose
|
@Expose
|
||||||
private String userId;
|
private @Nullable String userId;
|
||||||
@SerializedName("cUserId")
|
@SerializedName("cUserId")
|
||||||
@Expose
|
@Expose
|
||||||
private String cUserId;
|
private @Nullable String cUserId;
|
||||||
@SerializedName("securityStatus")
|
@SerializedName("securityStatus")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer securityStatus;
|
private @Nullable Integer securityStatus;
|
||||||
@SerializedName("pwd")
|
@SerializedName("pwd")
|
||||||
@Expose
|
@Expose
|
||||||
private Integer pwd;
|
private @Nullable Integer pwd;
|
||||||
@SerializedName("code")
|
@SerializedName("code")
|
||||||
@Expose
|
@Expose
|
||||||
private String code;
|
private @Nullable String code;
|
||||||
@SerializedName("desc")
|
@SerializedName("desc")
|
||||||
@Expose
|
@Expose
|
||||||
private String desc;
|
private @Nullable String desc;
|
||||||
@SerializedName("location")
|
@SerializedName("location")
|
||||||
@Expose
|
@Expose
|
||||||
private String location;
|
private @Nullable String location;
|
||||||
@SerializedName("captchaUrl")
|
@SerializedName("captchaUrl")
|
||||||
@Expose
|
@Expose
|
||||||
private Object captchaUrl;
|
private @Nullable String captchaUrl;
|
||||||
|
@SerializedName("callback")
|
||||||
public String getSsecurity() {
|
@Expose
|
||||||
return ssecurity != null ? ssecurity : "";
|
private @Nullable String callback;
|
||||||
}
|
@SerializedName("notificationUrl")
|
||||||
|
@Expose
|
||||||
public String getUserId() {
|
private @Nullable String notificationUrl;
|
||||||
return userId != null ? userId : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getcUserId() {
|
|
||||||
return cUserId != null ? cUserId : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPassToken() {
|
|
||||||
return passToken != null ? passToken : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getLocation() {
|
|
||||||
return location != null ? location : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getQs() {
|
public String getQs() {
|
||||||
return qs;
|
return Objects.requireNonNullElse(qs, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPsecurity() {
|
public String getPsecurity() {
|
||||||
return psecurity;
|
return Objects.requireNonNullElse(psecurity, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getNonce() {
|
public Integer getNonce() {
|
||||||
return nonce;
|
return Objects.requireNonNullElse(nonce, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCUserId() {
|
public String getSsecurity() {
|
||||||
return cUserId;
|
return Objects.requireNonNullElse(ssecurity, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassToken() {
|
||||||
|
return Objects.requireNonNullElse(passToken, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserId() {
|
||||||
|
return Objects.requireNonNullElse(userId, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getcUserId() {
|
||||||
|
return Objects.requireNonNullElse(cUserId, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getSecurityStatus() {
|
public Integer getSecurityStatus() {
|
||||||
return securityStatus;
|
return Objects.requireNonNullElse(securityStatus, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getPwd() {
|
public Integer getPwd() {
|
||||||
return pwd;
|
return Objects.requireNonNullElse(pwd, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return Objects.requireNonNullElse(code, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDesc() {
|
public String getDesc() {
|
||||||
return desc;
|
return Objects.requireNonNullElse(desc, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Object getCaptchaUrl() {
|
public String getLocation() {
|
||||||
return captchaUrl;
|
return Objects.requireNonNullElse(location, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCaptchaUrl(Object captchaUrl) {
|
public String getCaptchaUrl() {
|
||||||
this.captchaUrl = captchaUrl;
|
return Objects.requireNonNullElse(captchaUrl, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCallback() {
|
||||||
|
return Objects.requireNonNullElse(callback, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNotificationUrl() {
|
||||||
|
return Objects.requireNonNullElse(notificationUrl, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2026 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.miio.internal.cloud;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.binding.miio.internal.cloud.MiCloudConnector.CloudLoginState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for a listener on the {@link org.openhab.binding.miio.internal.cloud.MiCloudConnector}.
|
||||||
|
* Informs when login information is updated.
|
||||||
|
*
|
||||||
|
* @author Marcel Verpaalen - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public interface CloudLoginListener {
|
||||||
|
/**
|
||||||
|
* Callback method for the {@link CloudLoginListener}
|
||||||
|
*
|
||||||
|
* @param image the captcha image as jpg byte array
|
||||||
|
*/
|
||||||
|
void onLoginImage(byte[] image);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback method for the {@link CloudLoginListener}
|
||||||
|
*
|
||||||
|
* @param loginState the current login state
|
||||||
|
* @param status Status text
|
||||||
|
*/
|
||||||
|
void onStatusUpdated(CloudLoginState loginState, String status);
|
||||||
|
}
|
||||||
+169
@@ -24,6 +24,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
@@ -38,6 +40,9 @@ import org.eclipse.jdt.annotation.Nullable;
|
|||||||
import org.openhab.binding.miio.internal.MiIoCryptoException;
|
import org.openhab.binding.miio.internal.MiIoCryptoException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The {@link CloudUtil} class is used for supporting functions for Xiaomi cloud access
|
* The {@link CloudUtil} class is used for supporting functions for Xiaomi cloud access
|
||||||
*
|
*
|
||||||
@@ -47,6 +52,7 @@ import org.slf4j.Logger;
|
|||||||
public class CloudUtil {
|
public class CloudUtil {
|
||||||
|
|
||||||
private static final Random RANDOM = new SecureRandom();
|
private static final Random RANDOM = new SecureRandom();
|
||||||
|
private static final String UNEXPECTED = "Unexpected :";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the Xiaomi cloud device info with tokens to file
|
* Saves the Xiaomi cloud device info with tokens to file
|
||||||
@@ -115,6 +121,87 @@ public class CloudUtil {
|
|||||||
sb.toString().getBytes(StandardCharsets.UTF_8));
|
sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate encrypted signature (SHA1, base64) for the request.
|
||||||
|
*
|
||||||
|
* @param url the full request url
|
||||||
|
* @param method HTTP method (GET/POST)
|
||||||
|
* @param signedNonce secret key for encryption (Base64 encoded)
|
||||||
|
* @param params request params
|
||||||
|
* @return BASE64 encoded SHA1 signature
|
||||||
|
*/
|
||||||
|
public static String generateEncSignature(String url, String method, String signedNonce, Map<String, String> params)
|
||||||
|
throws MiIoCryptoException {
|
||||||
|
String path = url.split("com", 2)[1].replace("/app/", "/");
|
||||||
|
List<String> signatureParams = new ArrayList<>();
|
||||||
|
signatureParams.add(method.toUpperCase());
|
||||||
|
signatureParams.add(path);
|
||||||
|
|
||||||
|
for (Map.Entry<String, String> entry : new TreeMap<>(params).entrySet()) {
|
||||||
|
signatureParams.add(entry.getKey() + "=" + entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureParams.add(signedNonce);
|
||||||
|
String signatureString = String.join("&", signatureParams);
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||||
|
byte[] digest = md.digest(signatureString.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new MiIoCryptoException("SHA-1 algorithm not found", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate encrypted parameters for the request.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method creates a copy of the provided params map and performs the following
|
||||||
|
* steps:
|
||||||
|
* <ol>
|
||||||
|
* <li>Compute an rc4_hash__ value used by the Xiaomi API.</li>
|
||||||
|
* <li>Encrypt each parameter value using RC4 with the provided signedNonce.</li>
|
||||||
|
* <li>Add signature, ssecurity and _nonce fields required by the API.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param url the full request url
|
||||||
|
* @param method HTTP method (GET/POST)
|
||||||
|
* @param signedNonce secret key for encryption (Base64 encoded)
|
||||||
|
* @param nonce nonce value
|
||||||
|
* @param params request params (will not be modified)
|
||||||
|
* @param ssecurity ssecurity value
|
||||||
|
* @return Map with encrypted parameters and required fields (a new map)
|
||||||
|
* @throws MiIoCryptoException when encryption or signature generation fails
|
||||||
|
*/
|
||||||
|
public static Map<String, String> generateEncParams(String url, String method, String signedNonce, String nonce,
|
||||||
|
Map<String, String> params, String ssecurity) throws MiIoCryptoException {
|
||||||
|
Map<String, String> encParams = new TreeMap<>(params);
|
||||||
|
// Step 1: Add rc4_hash__
|
||||||
|
String rc4Hash = generateEncSignature(url, method, signedNonce, encParams);
|
||||||
|
encParams.put("rc4_hash__", rc4Hash);
|
||||||
|
// Step 2: Encrypt all values with RC4
|
||||||
|
for (Map.Entry<String, String> entry : new TreeMap<>(encParams).entrySet()) {
|
||||||
|
String encrypted = CloudCrypto.encryptRc4(signedNonce, entry.getValue());
|
||||||
|
encParams.put(entry.getKey(), encrypted);
|
||||||
|
}
|
||||||
|
// Step 3: Add signature, ssecurity, _nonce
|
||||||
|
encParams.put("signature", generateEncSignature(url, method, signedNonce, encParams));
|
||||||
|
encParams.put("ssecurity", ssecurity);
|
||||||
|
encParams.put("_nonce", nonce);
|
||||||
|
return encParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a time-dependent nonce value encoded as BASE64.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The generated value embeds a random long and a minute-resolution timestamp
|
||||||
|
* (milli / 60000). The result is safe for use with Xiaomi's signing API.
|
||||||
|
*
|
||||||
|
* @param milli current epoch milliseconds used to derive the timestamp portion
|
||||||
|
* @return BASE64 encoded nonce string
|
||||||
|
* @throws IOException never thrown in current implementation, kept for API compatibility
|
||||||
|
*/
|
||||||
public static String generateNonce(long milli) throws IOException {
|
public static String generateNonce(long milli) throws IOException {
|
||||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
DataOutputStream dataOutputStream = new DataOutputStream(output);
|
DataOutputStream dataOutputStream = new DataOutputStream(output);
|
||||||
@@ -124,6 +211,16 @@ public class CloudUtil {
|
|||||||
return Base64.getEncoder().encodeToString(output.toByteArray());
|
return Base64.getEncoder().encodeToString(output.toByteArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a signed nonce by concatenating the decoded ssecret and decoded nonce,
|
||||||
|
* then computing the SHA-256 hash and returning it as BASE64.
|
||||||
|
*
|
||||||
|
* @param ssecret base64-encoded secret (ssecurity)
|
||||||
|
* @param nonce base64-encoded nonce (from {@link #generateNonce})
|
||||||
|
* @return BASE64 encoded SHA-256 hash of (ssecret || nonce)
|
||||||
|
* @throws IOException if base64 decoding fails (propagated for callers)
|
||||||
|
* @throws MiIoCryptoException on crypto related errors
|
||||||
|
*/
|
||||||
public static String signedNonce(String ssecret, String nonce) throws IOException, MiIoCryptoException {
|
public static String signedNonce(String ssecret, String nonce) throws IOException, MiIoCryptoException {
|
||||||
byte[] byteArrayS = Base64.getDecoder().decode(ssecret.getBytes(StandardCharsets.UTF_8));
|
byte[] byteArrayS = Base64.getDecoder().decode(ssecret.getBytes(StandardCharsets.UTF_8));
|
||||||
byte[] byteArrayN = Base64.getDecoder().decode(nonce.getBytes(StandardCharsets.UTF_8));
|
byte[] byteArrayN = Base64.getDecoder().decode(nonce.getBytes(StandardCharsets.UTF_8));
|
||||||
@@ -133,8 +230,80 @@ public class CloudUtil {
|
|||||||
return CloudCrypto.sha256Hash(output.toByteArray());
|
return CloudCrypto.sha256Hash(output.toByteArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a byte array to a filesystem path using NIO.
|
||||||
|
*
|
||||||
|
* @param bFile data to write
|
||||||
|
* @param fileDest destination path as string
|
||||||
|
* @throws IOException when writing to the filesystem fails
|
||||||
|
*/
|
||||||
public static void writeBytesToFileNio(byte[] bFile, String fileDest) throws IOException {
|
public static void writeBytesToFileNio(byte[] bFile, String fileDest) throws IOException {
|
||||||
Path path = Paths.get(fileDest);
|
Path path = Paths.get(fileDest);
|
||||||
Files.write(path, bFile);
|
Files.write(path, bFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Xiaomi JSON response that may contain a non-JSON prefix.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Xiaomi sometimes prefixes JSON payloads with the string "&&&START&&&".
|
||||||
|
* This helper removes that prefix when present. If the prefix is not found
|
||||||
|
* the method returns UNEXPECTED + original data to aid debugging.
|
||||||
|
*
|
||||||
|
* @param data raw response string
|
||||||
|
* @return cleaned JSON string or an UNEXPECTED-prefixed value
|
||||||
|
*/
|
||||||
|
public static String parseJson(String data) {
|
||||||
|
if (data.contains("&&&START&&&")) {
|
||||||
|
return data.replace("&&&START&&&", "");
|
||||||
|
} else {
|
||||||
|
return UNEXPECTED.concat(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts a string value from a JsonObject.
|
||||||
|
*
|
||||||
|
* @param json The JsonObject to extract from
|
||||||
|
* @param key The key to look up
|
||||||
|
* @param defaultValue The default value if key is missing or null
|
||||||
|
* @return The string value or defaultValue if not available
|
||||||
|
*/
|
||||||
|
public static String getJsonString(@Nullable JsonObject json, String key, String defaultValue) {
|
||||||
|
if (json == null || !json.has(key)) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
JsonElement element = json.get(key);
|
||||||
|
if (element == null || element.isJsonNull()) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return element.getAsString();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts an integer value from a JsonObject.
|
||||||
|
*
|
||||||
|
* @param json The JsonObject to extract from
|
||||||
|
* @param key The key to look up
|
||||||
|
* @param defaultValue The default value if key is missing or null
|
||||||
|
* @return The integer value or defaultValue if not available
|
||||||
|
*/
|
||||||
|
public static int getJsonInt(@Nullable JsonObject json, String key, int defaultValue) {
|
||||||
|
if (json == null || !json.has(key)) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
JsonElement element = json.get(key);
|
||||||
|
if (element == null || element.isJsonNull()) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return element.getAsInt();
|
||||||
|
} catch (IllegalStateException | NumberFormatException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+300
-212
@@ -17,6 +17,9 @@ import java.net.CookieStore;
|
|||||||
import java.net.HttpCookie;
|
import java.net.HttpCookie;
|
||||||
import java.net.MalformedURLException;
|
import java.net.MalformedURLException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -25,25 +28,25 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
import org.eclipse.jetty.client.HttpClient;
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
import org.eclipse.jetty.client.HttpResponseException;
|
import org.eclipse.jetty.client.HttpResponseException;
|
||||||
import org.eclipse.jetty.client.api.ContentResponse;
|
import org.eclipse.jetty.client.api.ContentResponse;
|
||||||
import org.eclipse.jetty.client.api.Request;
|
import org.eclipse.jetty.client.api.Request;
|
||||||
import org.eclipse.jetty.client.util.FormContentProvider;
|
import org.eclipse.jetty.client.util.FormContentProvider;
|
||||||
import org.eclipse.jetty.http.HttpHeader;
|
import org.eclipse.jetty.http.HttpHeader;
|
||||||
import org.eclipse.jetty.http.HttpMethod;
|
|
||||||
import org.eclipse.jetty.http.HttpStatus;
|
import org.eclipse.jetty.http.HttpStatus;
|
||||||
import org.eclipse.jetty.util.Fields;
|
import org.eclipse.jetty.util.Fields;
|
||||||
import org.openhab.binding.miio.internal.MiIoCrypto;
|
|
||||||
import org.openhab.binding.miio.internal.MiIoCryptoException;
|
import org.openhab.binding.miio.internal.MiIoCryptoException;
|
||||||
import org.openhab.binding.miio.internal.Utils;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -63,43 +66,96 @@ import com.google.gson.JsonSyntaxException;
|
|||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class MiCloudConnector {
|
public class MiCloudConnector {
|
||||||
|
|
||||||
private static final int REQUEST_TIMEOUT_SECONDS = 10;
|
protected static final int REQUEST_TIMEOUT_SECONDS = 10;
|
||||||
private static final String UNEXPECTED = "Unexpected :";
|
protected static final String AGENT_ID = (new Random().ints(65, 70).limit(13)
|
||||||
private static final String AGENT_ID = (new Random().ints(65, 70).limit(13)
|
|
||||||
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
|
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
|
||||||
private static final String USERAGENT = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-" + AGENT_ID
|
protected static final String USERAGENT = "Android-9.1.1-1.0.0-ONEPLUS A3010-136-" + AGENT_ID
|
||||||
+ " APP/xiaomi.smarthome APPV/62830";
|
+ " APP/com.xiaomi.mihome APPV/10.5.201";
|
||||||
private static Locale locale = Locale.getDefault();
|
protected static Locale locale = Locale.getDefault();
|
||||||
private static final TimeZone TZ = TimeZone.getDefault();
|
protected static final TimeZone TZ = TimeZone.getDefault();
|
||||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("OOOO");
|
protected static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("OOOO");
|
||||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
protected static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||||
|
protected List<CloudLoginListener> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
private final String clientId;
|
protected String username;
|
||||||
|
protected String password;
|
||||||
|
protected String userId = "";
|
||||||
|
protected String serviceToken = "";
|
||||||
|
protected String ssecurity = "";
|
||||||
|
protected final String clientId;
|
||||||
|
|
||||||
private String username;
|
protected int loginFailedCounter = 0;
|
||||||
private String password;
|
protected HttpClient httpClient;
|
||||||
private String userId = "";
|
protected String sign = "";
|
||||||
private String serviceToken = "";
|
protected CloudLoginState loginState = CloudLoginState.INITIATING;
|
||||||
private String ssecurity = "";
|
|
||||||
private int loginFailedCounter = 0;
|
|
||||||
private HttpClient httpClient;
|
|
||||||
|
|
||||||
|
public enum CloudLoginState {
|
||||||
|
INITIATING("Initiating cloud connection"),
|
||||||
|
ACCESS_DENIED("Access denied by Xiaomi cloud"),
|
||||||
|
AWAITING_2FA("Awaiting two-factor authentication"),
|
||||||
|
AWAITING_CAPTCHA("Awaiting captcha response"),
|
||||||
|
AWAITING_QRLOGIN("Awaiting QR code scan"),
|
||||||
|
CAPTCHA_FAILED("Captcha verification failed"),
|
||||||
|
ONLINE("Connected to Xiaomi cloud");
|
||||||
|
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
CloudLoginState(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CloudLoginMode {
|
||||||
|
QRCODE,
|
||||||
|
PASSWORD,
|
||||||
|
TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// {"code":66108,"desc":"发送的参数信息不合法"} wrong parameters
|
||||||
|
// code":10012,"action":"","title":"","tips":"Onwettig verzoek","desc":"非法请求"}
|
||||||
private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class);
|
private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class);
|
||||||
|
|
||||||
public MiCloudConnector(String username, String password, HttpClient httpClient) throws MiCloudException {
|
public static String generateClientId() {
|
||||||
this.username = username;
|
return (new Random().ints(97, 122 + 1).limit(6)
|
||||||
this.password = password;
|
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public MiCloudConnector(@Nullable String username, @Nullable String password, HttpClient httpClient,
|
||||||
|
@Nullable String clientId, @Nullable String userId, @Nullable String serviceToken,
|
||||||
|
@Nullable String ssecurity) throws MiCloudException {
|
||||||
|
this.username = username != null ? username : "";
|
||||||
|
this.password = password != null ? password : "";
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
if (!checkCredentials()) {
|
this.userId = userId != null ? userId : "";
|
||||||
|
this.serviceToken = serviceToken != null ? serviceToken : "";
|
||||||
|
this.ssecurity = ssecurity != null ? ssecurity : "";
|
||||||
|
this.clientId = clientId != null ? clientId : generateClientId();
|
||||||
|
// Only require username/password when TOKEN fields are absent and QR is not being used
|
||||||
|
if (!hasTokenCredentials() && !checkCredentials()) {
|
||||||
throw new MiCloudException("username or password can't be empty");
|
throw new MiCloudException("username or password can't be empty");
|
||||||
}
|
}
|
||||||
clientId = (new Random().ints(97, 122 + 1).limit(6)
|
}
|
||||||
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
|
|
||||||
|
public MiCloudConnector(@Nullable String username, @Nullable String password, HttpClient httpClient)
|
||||||
|
throws MiCloudException {
|
||||||
|
this.username = username != null ? username : "";
|
||||||
|
this.password = password != null ? password : "";
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
if (!hasTokenCredentials() && !checkCredentials()) {
|
||||||
|
throw new MiCloudException("username or password can't be empty");
|
||||||
|
}
|
||||||
|
clientId = generateClientId();
|
||||||
}
|
}
|
||||||
|
|
||||||
void startClient() throws MiCloudException {
|
void startClient() throws MiCloudException {
|
||||||
if (!httpClient.isStarted()) {
|
if (!httpClient.isStarted()) {
|
||||||
try {
|
try {
|
||||||
|
// setFollowRedirects must be configured before the client is started
|
||||||
|
httpClient.setFollowRedirects(true);
|
||||||
httpClient.start();
|
httpClient.start();
|
||||||
CookieStore cookieStore = httpClient.getCookieStore();
|
CookieStore cookieStore = httpClient.getCookieStore();
|
||||||
// set default cookies
|
// set default cookies
|
||||||
@@ -121,7 +177,7 @@ public class MiCloudConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean checkCredentials() {
|
protected boolean checkCredentials() {
|
||||||
if (username.trim().isEmpty() || password.trim().isEmpty()) {
|
if (username.trim().isEmpty() || password.trim().isEmpty()) {
|
||||||
logger.info("Xiaomi Cloud: username or password missing.");
|
logger.info("Xiaomi Cloud: username or password missing.");
|
||||||
return false;
|
return false;
|
||||||
@@ -129,6 +185,14 @@ public class MiCloudConnector {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when all three TOKEN credential fields (userId, serviceToken, ssecurity) are non-empty,
|
||||||
|
* meaning a full cloud session can be restored without username/password.
|
||||||
|
*/
|
||||||
|
protected boolean hasTokenCredentials() {
|
||||||
|
return !userId.trim().isEmpty() && !serviceToken.trim().isEmpty() && !ssecurity.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
private String getApiUrl(String country) {
|
private String getApiUrl(String country) {
|
||||||
return "https://" + ("cn".equalsIgnoreCase(country.trim()) ? "" : country.trim().toLowerCase() + ".")
|
return "https://" + ("cn".equalsIgnoreCase(country.trim()) ? "" : country.trim().toLowerCase() + ".")
|
||||||
+ "api.io.mi.com/app";
|
+ "api.io.mi.com/app";
|
||||||
@@ -138,22 +202,13 @@ public class MiCloudConnector {
|
|||||||
return clientId;
|
return clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
String parseJson(String data) {
|
public Optional<String> getMapUrl(String vacuumMap, String country) throws MiCloudException {
|
||||||
if (data.contains("&&&START&&&")) {
|
|
||||||
return data.replace("&&&START&&&", "");
|
|
||||||
} else {
|
|
||||||
return UNEXPECTED.concat(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMapUrl(String vacuumMap, String country) throws MiCloudException {
|
|
||||||
String url = getApiUrl(country) + "/home/getmapfileurl";
|
String url = getApiUrl(country) + "/home/getmapfileurl";
|
||||||
Map<String, String> map = new HashMap<>();
|
Map<String, String> map = new HashMap<>();
|
||||||
map.put("data", "{\"obj_name\":\"" + vacuumMap + "\"}");
|
map.put("data", "{\"obj_name\":\"" + vacuumMap + "\"}");
|
||||||
String mapResponse = request(url, map);
|
|
||||||
logger.trace("Response: {}", mapResponse);
|
|
||||||
String errorMsg = "";
|
|
||||||
try {
|
try {
|
||||||
|
String mapResponse = request(url, map);
|
||||||
|
logger.trace("Response: {}", mapResponse);
|
||||||
JsonElement response = JsonParser.parseString(mapResponse);
|
JsonElement response = JsonParser.parseString(mapResponse);
|
||||||
if (response.isJsonObject()) {
|
if (response.isJsonObject()) {
|
||||||
logger.debug("Received JSON message {}", response);
|
logger.debug("Received JSON message {}", response);
|
||||||
@@ -161,21 +216,20 @@ public class MiCloudConnector {
|
|||||||
&& response.getAsJsonObject().get("result").isJsonObject()) {
|
&& response.getAsJsonObject().get("result").isJsonObject()) {
|
||||||
JsonObject jo = response.getAsJsonObject().get("result").getAsJsonObject();
|
JsonObject jo = response.getAsJsonObject().get("result").getAsJsonObject();
|
||||||
if (jo.has("url")) {
|
if (jo.has("url")) {
|
||||||
return jo.get("url").getAsString();
|
return Optional.of(jo.get("url").getAsString());
|
||||||
} else {
|
} else {
|
||||||
errorMsg = "Could not get url";
|
throw new MiCloudException("Could not get url from response");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMsg = "Could not get result";
|
throw new MiCloudException("Could not get result from response");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMsg = "Received message is invalid JSON";
|
throw new MiCloudException("Received message is invalid JSON");
|
||||||
}
|
}
|
||||||
} catch (ClassCastException | IllegalStateException e) {
|
} catch (JsonParseException | ClassCastException | IllegalStateException e) {
|
||||||
errorMsg = "Received message could not be parsed";
|
logger.debug("Error parsing map URL response: {}", e.getMessage());
|
||||||
|
throw new MiCloudException("Received message could not be parsed", e);
|
||||||
}
|
}
|
||||||
logger.debug("{}: {}", errorMsg, mapResponse);
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDeviceStatus(String device, String country) throws MiCloudException {
|
public String getDeviceStatus(String device, String country) throws MiCloudException {
|
||||||
@@ -220,9 +274,9 @@ public class MiCloudConnector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<CloudDeviceDTO> getDevices(String country) {
|
public List<CloudDeviceDTO> getDevices(String country) {
|
||||||
final String response = getDeviceString(country);
|
|
||||||
List<CloudDeviceDTO> devicesList = new ArrayList<>();
|
List<CloudDeviceDTO> devicesList = new ArrayList<>();
|
||||||
try {
|
try {
|
||||||
|
final String response = getDeviceString(country);
|
||||||
final JsonElement resp = JsonParser.parseString(response);
|
final JsonElement resp = JsonParser.parseString(response);
|
||||||
if (resp.isJsonObject()) {
|
if (resp.isJsonObject()) {
|
||||||
final JsonObject jor = resp.getAsJsonObject();
|
final JsonObject jor = resp.getAsJsonObject();
|
||||||
@@ -241,6 +295,10 @@ public class MiCloudConnector {
|
|||||||
} else {
|
} else {
|
||||||
logger.debug("Response is not a json object: '{}'", response);
|
logger.debug("Response is not a json object: '{}'", response);
|
||||||
}
|
}
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
// loginFailedCounter is already managed by request() for network and authentication failures;
|
||||||
|
// do not double-count here
|
||||||
|
logger.debug("Could not retrieve device list from server {}: {}", country, e.getMessage());
|
||||||
} catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
|
} catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
|
||||||
loginFailedCounter++;
|
loginFailedCounter++;
|
||||||
logger.info("Error while parsing devices: {}", e.getMessage());
|
logger.info("Error while parsing devices: {}", e.getMessage());
|
||||||
@@ -248,20 +306,15 @@ public class MiCloudConnector {
|
|||||||
return devicesList;
|
return devicesList;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDeviceString(String country) {
|
public String getDeviceString(String country) throws MiCloudException {
|
||||||
String resp;
|
// Let request() exceptions propagate directly; request() manages loginFailedCounter for network failures.
|
||||||
try {
|
String resp = request("/home/device_list_page", country, "{\"getVirtualModel\":true,\"getHuamiDevices\":1}");
|
||||||
resp = request("/home/device_list_page", country, "{\"getVirtualModel\":true,\"getHuamiDevices\":1}");
|
logger.trace("Get devices response: {}", resp);
|
||||||
logger.trace("Get devices response: {}", resp);
|
if (resp.length() > 2) {
|
||||||
if (resp.length() > 2) {
|
CloudUtil.saveDeviceInfoFile(resp, country, logger);
|
||||||
CloudUtil.saveDeviceInfoFile(resp, country, logger);
|
return resp;
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
} catch (MiCloudException e) {
|
|
||||||
logger.info("{}", e.getMessage());
|
|
||||||
loginFailedCounter++;
|
|
||||||
}
|
}
|
||||||
return "";
|
throw new MiCloudException("Empty device list response for server " + country);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String request(String urlPart, String country, String params) throws MiCloudException {
|
public String request(String urlPart, String country, String params) throws MiCloudException {
|
||||||
@@ -306,6 +359,7 @@ public class MiCloudConnector {
|
|||||||
}
|
}
|
||||||
String method = "POST";
|
String method = "POST";
|
||||||
request.method(method);
|
request.method(method);
|
||||||
|
dumpCookies(url, false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String nonce = CloudUtil.generateNonce(System.currentTimeMillis());
|
String nonce = CloudUtil.generateNonce(System.currentTimeMillis());
|
||||||
@@ -323,20 +377,31 @@ public class MiCloudConnector {
|
|||||||
if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
|
if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
|
||||||
&& response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
|
&& response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
|
||||||
this.serviceToken = "";
|
this.serviceToken = "";
|
||||||
|
// Notify listeners that authentication was rejected so callers can re-authenticate.
|
||||||
|
// Only fire once when transitioning away from ONLINE to avoid repeated callbacks.
|
||||||
|
if (loginState == CloudLoginState.ONLINE) {
|
||||||
|
updateLoginState(CloudLoginState.ACCESS_DENIED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return response.getContentAsString();
|
return response.getContentAsString();
|
||||||
} catch (HttpResponseException e) {
|
} catch (HttpResponseException e) {
|
||||||
serviceToken = "";
|
serviceToken = "";
|
||||||
logger.debug("Error while executing request to {} :{}", url, e.getMessage());
|
logger.debug("Error while executing request to {} :{}", url, e.getMessage());
|
||||||
loginFailedCounter++;
|
loginFailedCounter++;
|
||||||
} catch (InterruptedException | TimeoutException | ExecutionException | IOException e) {
|
throw new MiCloudException("Error while executing request: " + e.getMessage(), e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
loginFailedCounter++;
|
||||||
|
throw new MiCloudException("Request interrupted: " + e.getMessage(), e);
|
||||||
|
} catch (TimeoutException | ExecutionException | IOException e) {
|
||||||
logger.debug("Error while executing request to {} :{}", url, e.getMessage());
|
logger.debug("Error while executing request to {} :{}", url, e.getMessage());
|
||||||
loginFailedCounter++;
|
loginFailedCounter++;
|
||||||
|
throw new MiCloudException("Error while executing request: " + e.getMessage(), e);
|
||||||
} catch (MiIoCryptoException e) {
|
} catch (MiIoCryptoException e) {
|
||||||
logger.debug("Error while decrypting response of request to {} :{}", url, e.getMessage(), e);
|
logger.debug("Error while decrypting response of request to {} :{}", url, e.getMessage(), e);
|
||||||
loginFailedCounter++;
|
loginFailedCounter++;
|
||||||
|
throw new MiCloudException("Error decrypting response: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
|
private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
|
||||||
@@ -346,29 +411,64 @@ public class MiCloudConnector {
|
|||||||
cookieStore.add(URI.create("https://" + domain), cookie);
|
cookieStore.add(URI.create("https://" + domain), cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected byte[] fetchImageBytes(String url, int timeoutSeconds) throws MiCloudException {
|
||||||
|
try {
|
||||||
|
Request request = httpClient.newRequest(url).agent(USERAGENT).method("GET").timeout(timeoutSeconds,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
final ContentResponse response = request.send();
|
||||||
|
if (response.getStatus() >= HttpStatus.BAD_REQUEST_400) {
|
||||||
|
throw new MiCloudException("Failed to fetch image from " + url + " status=" + response.getStatus());
|
||||||
|
}
|
||||||
|
return response.getContent();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new MiCloudException("Image fetch interrupted: " + e.getMessage(), e);
|
||||||
|
} catch (TimeoutException | ExecutionException e) {
|
||||||
|
throw new MiCloudException("Error fetching image from " + url + ": " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Path saveBytesToTempFile(byte[] content, String prefix, String suffix) throws MiCloudException {
|
||||||
|
try {
|
||||||
|
Path tmp = Files.createTempFile(prefix, suffix);
|
||||||
|
Files.write(tmp, content, StandardOpenOption.WRITE);
|
||||||
|
return tmp;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new MiCloudException("Error writing temporary image file: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected byte[] fetchAndInformImage(String url, int timeoutSeconds, String tempPrefix) throws MiCloudException {
|
||||||
|
byte[] content = fetchImageBytes(url, timeoutSeconds);
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
try {
|
||||||
|
Path path = saveBytesToTempFile(content, tempPrefix, ".jpg");
|
||||||
|
logger.trace("Saved login image to {} -> {} bytes", path.toAbsolutePath(), content.length);
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
logger.debug("Could not save image to temp file: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
informImageListeners(content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized boolean login() {
|
public synchronized boolean login() {
|
||||||
|
logger.debug("client Id={}", clientId);
|
||||||
|
return login("");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean login(String captchaResponse) {
|
||||||
|
if (!userId.isEmpty() && !serviceToken.isEmpty()) {
|
||||||
|
logger.debug("Xiaomi cloud login using stored token for userId {}", userId);
|
||||||
|
updateLoginState(CloudLoginState.ONLINE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!checkCredentials()) {
|
if (!checkCredentials()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!userId.isEmpty() && !serviceToken.isEmpty()) {
|
logger.debug("Xiaomi cloud login with userid {} - no token available", username);
|
||||||
return true;
|
updateLoginState(CloudLoginState.ACCESS_DENIED);
|
||||||
}
|
return false;
|
||||||
logger.debug("Xiaomi cloud login with userid {}", username);
|
|
||||||
try {
|
|
||||||
if (loginRequest()) {
|
|
||||||
loginFailedCounter = 0;
|
|
||||||
} else {
|
|
||||||
loginFailedCounter++;
|
|
||||||
logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
|
|
||||||
}
|
|
||||||
} catch (MiCloudException e) {
|
|
||||||
logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
|
|
||||||
loginFailedCounter++;
|
|
||||||
serviceToken = "";
|
|
||||||
loginFailedCounterCheck();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loginFailedCounterCheck() {
|
void loginFailedCounterCheck() {
|
||||||
@@ -378,141 +478,67 @@ public class MiCloudConnector {
|
|||||||
dumpCookies(".mi.com", true);
|
dumpCookies(".mi.com", true);
|
||||||
serviceToken = "";
|
serviceToken = "";
|
||||||
loginFailedCounter = 0;
|
loginFailedCounter = 0;
|
||||||
|
updateLoginState(CloudLoginState.ACCESS_DENIED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected boolean loginRequest() throws MiCloudException {
|
protected void updateLoginState(CloudLoginState state) {
|
||||||
try {
|
loginState = state;
|
||||||
startClient();
|
for (CloudLoginListener listener : listeners) {
|
||||||
String sign = loginStep1();
|
logger.trace("inform listener {}, state {}", listener, state);
|
||||||
String location;
|
try {
|
||||||
if (!sign.startsWith("http")) {
|
listener.onStatusUpdated(state, state.getDescription());
|
||||||
location = loginStep2(sign);
|
} catch (Exception e) {
|
||||||
} else {
|
logger.debug("Could not inform listener {}: {}: ", listener, e.getMessage(), e);
|
||||||
location = sign; // seems we already have login location
|
|
||||||
}
|
}
|
||||||
final ContentResponse responseStep3 = loginStep3(location);
|
}
|
||||||
switch (responseStep3.getStatus()) {
|
}
|
||||||
case HttpStatus.FORBIDDEN_403:
|
|
||||||
throw new MiCloudException("Access denied. Did you set the correct api-key and/or username?");
|
protected ContentResponse debugRequest(Request request)
|
||||||
case HttpStatus.OK_200:
|
throws InterruptedException, TimeoutException, ExecutionException {
|
||||||
return true;
|
ContentResponse response;
|
||||||
default:
|
traceRequest(request);
|
||||||
logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
|
response = request.send();
|
||||||
responseStep3.getReason(), responseStep3.getContentAsString());
|
traceResponse(response);
|
||||||
throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
|
dumpCookies(request.getHost() + request.getPath(), false);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void traceRequest(Request request) {
|
||||||
|
if (!logger.isTraceEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.trace("Xiaomi cloud request URL= {} {} {}", request.getMethod(), request.getHost(), request.getPath());
|
||||||
|
logger.trace("Xiaomi cloud request content req= {}",
|
||||||
|
request.getContent() == null ? "" : request.getContent().toString());
|
||||||
|
logger.trace("Xiaomi cloud request headers= {}", request.getHeaders().toString());
|
||||||
|
logger.trace("Xiaomi cloud request param= {}", request.getParams());
|
||||||
|
logger.trace("Xiaomi cloud request cookie= {}", request.getCookies().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void traceResponse(ContentResponse response) {
|
||||||
|
if (!logger.isTraceEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.trace("Xiaomi cloud response status = {}", response.getStatus());
|
||||||
|
logger.trace("Xiaomi cloud response response = {}", response);
|
||||||
|
logger.trace("Xiaomi cloud response content = {}", response.toString());
|
||||||
|
logger.trace("Xiaomi cloud response header = {}", response.getHeaders().toString());
|
||||||
|
logger.trace("Xiaomi cloud response content = {}", response.getContentAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void informImageListeners(byte[] image) {
|
||||||
|
for (CloudLoginListener listener : listeners) {
|
||||||
|
logger.trace("Inform listener {}, with image", listener);
|
||||||
|
try {
|
||||||
|
listener.onLoginImage(image);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Could not inform listener {}: {}: ", listener, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
} catch (InterruptedException | TimeoutException | ExecutionException e) {
|
|
||||||
throw new MiCloudException("Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
|
|
||||||
} catch (MiIoCryptoException e) {
|
|
||||||
throw new MiCloudException("Error decrypting. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
|
|
||||||
} catch (MalformedURLException | JsonParseException e) {
|
|
||||||
throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
|
protected ContentResponse loginStep3(String location)
|
||||||
final ContentResponse responseStep1;
|
|
||||||
|
|
||||||
logger.trace("Xiaomi Login step 1");
|
|
||||||
String url = "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true";
|
|
||||||
Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
||||||
request.agent(USERAGENT);
|
|
||||||
request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
|
|
||||||
request.cookie(new HttpCookie("userId", this.userId.length() > 0 ? this.userId : this.username));
|
|
||||||
|
|
||||||
responseStep1 = request.send();
|
|
||||||
final String content = responseStep1.getContentAsString();
|
|
||||||
logger.trace("Xiaomi Login step 1 content response= {}", content);
|
|
||||||
logger.trace("Xiaomi Login step 1 response = {}", responseStep1);
|
|
||||||
try {
|
|
||||||
JsonElement resp = JsonParser.parseString(parseJson(content));
|
|
||||||
CloudLogin1DTO jsonResp = GSON.fromJson(resp, CloudLogin1DTO.class);
|
|
||||||
final String sign = jsonResp != null ? jsonResp.getSign() : null;
|
|
||||||
if (sign != null && !sign.isBlank()) {
|
|
||||||
logger.trace("Xiaomi Login step 1 sign = {}", sign);
|
|
||||||
return sign;
|
|
||||||
} else {
|
|
||||||
logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
} catch (JsonParseException | IllegalStateException | ClassCastException e) {
|
|
||||||
throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String loginStep2(String sign) throws MiIoCryptoException, InterruptedException, TimeoutException,
|
|
||||||
ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
|
|
||||||
String passToken;
|
|
||||||
String cUserId;
|
|
||||||
|
|
||||||
logger.trace("Xiaomi Login step 2");
|
|
||||||
String url = "https://account.xiaomi.com/pass/serviceLoginAuth2";
|
|
||||||
Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
||||||
request.agent(USERAGENT);
|
|
||||||
request.method(HttpMethod.POST);
|
|
||||||
final ContentResponse responseStep2;
|
|
||||||
|
|
||||||
Fields fields = new Fields();
|
|
||||||
fields.put("sid", "xiaomiio");
|
|
||||||
fields.put("hash", Utils.getHex(MiIoCrypto.md5(password.getBytes())));
|
|
||||||
fields.put("callback", "https://sts.api.io.mi.com/sts");
|
|
||||||
fields.put("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue");
|
|
||||||
fields.put("user", username);
|
|
||||||
if (!sign.isEmpty()) {
|
|
||||||
fields.put("_sign", sign);
|
|
||||||
}
|
|
||||||
fields.put("_json", "true");
|
|
||||||
|
|
||||||
request.content(new FormContentProvider(fields));
|
|
||||||
responseStep2 = request.send();
|
|
||||||
|
|
||||||
final String content2 = responseStep2.getContentAsString();
|
|
||||||
logger.trace("Xiaomi login step 2 response = {}", responseStep2);
|
|
||||||
logger.trace("Xiaomi login step 2 content = {}", content2);
|
|
||||||
|
|
||||||
JsonElement resp2 = JsonParser.parseString(parseJson(content2));
|
|
||||||
CloudLoginDTO jsonResp = GSON.fromJson(resp2, CloudLoginDTO.class);
|
|
||||||
if (jsonResp == null) {
|
|
||||||
throw new MiCloudException("Error getting logon details from step 2: " + content2);
|
|
||||||
}
|
|
||||||
ssecurity = jsonResp.getSsecurity();
|
|
||||||
userId = jsonResp.getUserId();
|
|
||||||
cUserId = jsonResp.getcUserId();
|
|
||||||
passToken = jsonResp.getPassToken();
|
|
||||||
String location = jsonResp.getLocation();
|
|
||||||
String code = jsonResp.getCode();
|
|
||||||
|
|
||||||
logger.trace("Xiaomi login ssecurity = {}", ssecurity);
|
|
||||||
logger.trace("Xiaomi login userId = {}", userId);
|
|
||||||
logger.trace("Xiaomi login cUserId = {}", cUserId);
|
|
||||||
logger.trace("Xiaomi login passToken = {}", passToken);
|
|
||||||
logger.trace("Xiaomi login location = {}", location);
|
|
||||||
logger.trace("Xiaomi login code = {}", code);
|
|
||||||
if (0 != jsonResp.getSecurityStatus()) {
|
|
||||||
logger.debug("Xiaomi Cloud Step2 response: {}", parseJson(content2));
|
|
||||||
logger.debug(
|
|
||||||
"""
|
|
||||||
Xiaomi Login code: {}
|
|
||||||
SecurityStatus: {}
|
|
||||||
Pwd code: {}
|
|
||||||
Location logon URL: {}
|
|
||||||
In case of login issues check userId/password details are correct.
|
|
||||||
If login details are correct, try to logon using browser from the openHAB ip using the browser. Alternatively try to complete logon with above URL.\
|
|
||||||
""",
|
|
||||||
jsonResp.getCode(), jsonResp.getSecurityStatus(), jsonResp.getPwd(), jsonResp.getLocation());
|
|
||||||
}
|
|
||||||
if (logger.isTraceEnabled()) {
|
|
||||||
dumpCookies(url, false);
|
|
||||||
}
|
|
||||||
if (!location.isEmpty()) {
|
|
||||||
return location;
|
|
||||||
} else {
|
|
||||||
throw new MiCloudException("Error getting logon location URL. Return code: " + code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ContentResponse loginStep3(String location)
|
|
||||||
throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
|
throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
|
||||||
final ContentResponse responseStep3;
|
final ContentResponse responseStep3;
|
||||||
Request request;
|
Request request;
|
||||||
@@ -530,11 +556,12 @@ public class MiCloudConnector {
|
|||||||
String serviceToken = extractServiceToken(uri);
|
String serviceToken = extractServiceToken(uri);
|
||||||
if (!serviceToken.isEmpty()) {
|
if (!serviceToken.isEmpty()) {
|
||||||
this.serviceToken = serviceToken;
|
this.serviceToken = serviceToken;
|
||||||
|
updateLoginState(CloudLoginState.ONLINE);
|
||||||
}
|
}
|
||||||
return responseStep3;
|
return responseStep3;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void dumpCookies(String url, boolean delete) {
|
protected void dumpCookies(String url, boolean delete) {
|
||||||
if (logger.isTraceEnabled()) {
|
if (logger.isTraceEnabled()) {
|
||||||
try {
|
try {
|
||||||
URI uri = URI.create(url);
|
URI uri = URI.create(url);
|
||||||
@@ -558,14 +585,14 @@ public class MiCloudConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractServiceToken(URI uri) {
|
protected String extractServiceToken(URI uri) {
|
||||||
String serviceToken = "";
|
String serviceToken = "";
|
||||||
List<HttpCookie> cookies = httpClient.getCookieStore().get(uri);
|
List<HttpCookie> cookies = httpClient.getCookieStore().get(uri);
|
||||||
for (HttpCookie cookie : cookies) {
|
for (HttpCookie cookie : cookies) {
|
||||||
logger.trace("Cookie :{} --> {}", cookie.getName(), cookie.getValue());
|
logger.trace("Cookie :{} --> {}", cookie.getName(), cookie.getValue());
|
||||||
if (cookie.getName().contentEquals("serviceToken")) {
|
if (cookie.getName().contentEquals("serviceToken")) {
|
||||||
serviceToken = cookie.getValue();
|
serviceToken = cookie.getValue();
|
||||||
logger.debug("Xiaomi cloud logon successful.");
|
logger.debug("Xiaomi cloud login successful.");
|
||||||
logger.trace("Xiaomi cloud servicetoken: {}", serviceToken);
|
logger.trace("Xiaomi cloud servicetoken: {}", serviceToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,4 +602,65 @@ public class MiCloudConnector {
|
|||||||
public boolean hasLoginToken() {
|
public boolean hasLoginToken() {
|
||||||
return !serviceToken.isEmpty();
|
return !serviceToken.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(String userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServiceToken() {
|
||||||
|
return serviceToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServiceToken(String serviceToken) {
|
||||||
|
this.serviceToken = serviceToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSsecurity() {
|
||||||
|
return ssecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSsecurity(String ssecurity) {
|
||||||
|
this.ssecurity = ssecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<CloudLoginListener> getListeners() {
|
||||||
|
return listeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a {@link CloudLoginListener} to be called back, when data is received.
|
||||||
|
* If no {@link MessageSenderThread} exists, when the method is called, it is being set up.
|
||||||
|
*
|
||||||
|
* @param listener {@link CloudLoginListener} to be called back
|
||||||
|
*/
|
||||||
|
public void registerListener(CloudLoginListener listener) {
|
||||||
|
if (!getListeners().contains(listener)) {
|
||||||
|
logger.trace("Adding cloud listener {}", listener);
|
||||||
|
getListeners().add(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters a {@link CloudLoginListener}. If there are no listeners left,
|
||||||
|
* the {@link MessageSenderThread} is being closed.
|
||||||
|
*
|
||||||
|
* @param listener {@link CloudLoginListener} to be unregistered
|
||||||
|
*/
|
||||||
|
public void unregisterListener(CloudLoginListener listener) {
|
||||||
|
getListeners().remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits a 2FA response code to this connector's login flow.
|
||||||
|
* The default implementation is a no-op; subclasses that support 2FA must override this method.
|
||||||
|
*
|
||||||
|
* @param faCode the 2FA code entered by the user
|
||||||
|
*/
|
||||||
|
public void faResponse(String faCode) {
|
||||||
|
logger.debug("faResponse: 2FA is not supported by this connector type ({})", getClass().getSimpleName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2026 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.miio.internal.cloud;
|
||||||
|
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
|
import org.eclipse.jetty.client.api.ContentResponse;
|
||||||
|
import org.eclipse.jetty.client.api.Request;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link MiCloudQRConnector} class is login to the Xiaomi cloud using QR code authentication.
|
||||||
|
*
|
||||||
|
* @author Marcel Verpaalen - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class MiCloudQRConnector extends MiCloudConnector {
|
||||||
|
private static final int REQUEST_TIMEOUT_SECONDS = 120;
|
||||||
|
private static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 300;
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(MiCloudQRConnector.class);
|
||||||
|
|
||||||
|
public MiCloudQRConnector(String username, String password, HttpClient httpClient) throws MiCloudException {
|
||||||
|
super(username, password, httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MiCloudQRConnector(@Nullable String username, @Nullable String password, HttpClient httpClient,
|
||||||
|
@Nullable String clientId, @Nullable String userId, @Nullable String serviceToken,
|
||||||
|
@Nullable String ssecurity) throws MiCloudException {
|
||||||
|
super(username, password, httpClient, clientId, userId, serviceToken, ssecurity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class for holding login session data (QR code login)
|
||||||
|
*/
|
||||||
|
public static class LoginSessionData {
|
||||||
|
public final String imageUrl;
|
||||||
|
public final String loginUrl;
|
||||||
|
public final String longPollingUrl;
|
||||||
|
public final long expiresAt;
|
||||||
|
public final long timeout;
|
||||||
|
|
||||||
|
public LoginSessionData(String imageUrl, String loginUrl, String longPollingUrl, long expiresAt, long timeout) {
|
||||||
|
this.imageUrl = imageUrl;
|
||||||
|
this.longPollingUrl = longPollingUrl;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.timeout = timeout;
|
||||||
|
this.loginUrl = loginUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "LoginSessionData{" + "imageUrl='" + imageUrl + '\'' + ", loginUrl='" + loginUrl + '\''
|
||||||
|
+ ", longPollingUrl='" + longPollingUrl + '\'' + ", expiresAt=" + expiresAt + '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR code login never requires username or password — authentication is done entirely
|
||||||
|
* via the QR scan. Always returns true so the base constructor does not reject empty credentials.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean checkCredentials() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean login(String ignored) {
|
||||||
|
return login();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean login() {
|
||||||
|
logger.debug("Cloud login using QR code");
|
||||||
|
try {
|
||||||
|
startClient();
|
||||||
|
LoginSessionData sessionData = startLoginSession();
|
||||||
|
if (sessionData == null) {
|
||||||
|
logger.warn("Failed to start QR code login session");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQRImage(sessionData.imageUrl);
|
||||||
|
logger.debug("Xiaomi QR code login session started; waiting for QR code scan: {}", sessionData.loginUrl);
|
||||||
|
updateLoginState(CloudLoginState.AWAITING_QRLOGIN);
|
||||||
|
|
||||||
|
String location = checkSession(sessionData, (sessionData.timeout + REQUEST_TIMEOUT_SECONDS) * 1000L);
|
||||||
|
if (location == null || location.isEmpty()) {
|
||||||
|
logger.warn("Failed to fetch service token, location is empty");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sign = location;
|
||||||
|
ContentResponse res = loginStep3(location);
|
||||||
|
logger.debug("login step 3 response: {}: {}", res, res.getContentAsString());
|
||||||
|
|
||||||
|
if (res.getStatus() == 200) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.warn("Failed to login to Xiaomi cloud, status code: {}", res.getStatus());
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.debug("Xiaomi QR code login interrupted");
|
||||||
|
} catch (MiCloudException | TimeoutException | ExecutionException | MalformedURLException e) {
|
||||||
|
logger.warn("Error during Xiaomi QR code login", e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the QR code image from the given URL.
|
||||||
|
*
|
||||||
|
* @param url The URL of the QR code image
|
||||||
|
* @return The image bytes, or empty array if failed
|
||||||
|
*/
|
||||||
|
public byte[] getQRImage(String url) {
|
||||||
|
try {
|
||||||
|
return fetchAndInformImage(url, REQUEST_TIMEOUT_SECONDS, "miio-qr-");
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
logger.warn("Error getting QR image: {}", e.getMessage());
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a QR code login session (step 1 of Xiaomi login)
|
||||||
|
*
|
||||||
|
* @return LoginSessionData with QR code and session info, or null if failed
|
||||||
|
*/
|
||||||
|
public @Nullable LoginSessionData startLoginSession() {
|
||||||
|
logger.debug("Xiaomi login step 1 Starting QR code login session");
|
||||||
|
try {
|
||||||
|
String url = "https://account.xiaomi.com/longPolling/loginUrl";
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
ContentResponse response = httpClient.newRequest(url).param("_qrsize", "240")
|
||||||
|
.param("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue").param("callback", "https://sts.api.io.mi.com/sts")
|
||||||
|
.param("_hasLogo", "false").param("sid", "xiaomiio").param("serviceParam", "")
|
||||||
|
.param("_locale", locale.toString()).param("_dc", String.valueOf(now)).method("GET")
|
||||||
|
.agent(USERAGENT).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
|
||||||
|
|
||||||
|
String text = response.getContentAsString();
|
||||||
|
logger.debug("Xiaomi login step 1 login session request response code: {}", response.getStatus());
|
||||||
|
|
||||||
|
String json = CloudUtil.parseJson(text);
|
||||||
|
logger.debug("Xiaomi login step 1 login session request response: {}", json);
|
||||||
|
|
||||||
|
JsonObject responseData = GSON.fromJson(json, JsonObject.class);
|
||||||
|
if (responseData == null) {
|
||||||
|
logger.info("Xiaomi login step 1: Invalid JSON response");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String qr = CloudUtil.getJsonString(responseData, "qr", "");
|
||||||
|
String loginUrl = CloudUtil.getJsonString(responseData, "loginUrl", "");
|
||||||
|
String lp = CloudUtil.getJsonString(responseData, "lp", "");
|
||||||
|
int timeout = CloudUtil.getJsonInt(responseData, "timeout", DEFAULT_SESSION_TIMEOUT_SECONDS);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (qr.isEmpty() || loginUrl.isEmpty() || lp.isEmpty()) {
|
||||||
|
logger.info("Xiaomi login step 1: Missing required fields in response - qr: {}, loginUrl: {}, lp: {}",
|
||||||
|
!qr.isEmpty(), !loginUrl.isEmpty(), !lp.isEmpty());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long expiresAt = System.currentTimeMillis() + timeout * 1000L;
|
||||||
|
LoginSessionData sessionData = new LoginSessionData(qr, loginUrl, lp, expiresAt, timeout);
|
||||||
|
logger.debug("Xiaomi login step 1 login: {}", sessionData);
|
||||||
|
return sessionData;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.debug("QR login session start interrupted");
|
||||||
|
return null;
|
||||||
|
} catch (TimeoutException | ExecutionException e) {
|
||||||
|
logger.warn("Failed to start Xiaomi QR code login session", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the QR login session (long polling) and fetches login tokens if available.
|
||||||
|
*
|
||||||
|
* @param loginSessionData The session data from startLoginSession
|
||||||
|
* @param pollTimeoutMillis Optional poll timeout in milliseconds (null for default)
|
||||||
|
* @return The location URL if login is successful, null if still pending
|
||||||
|
* @throws MiCloudException if polling times out or fails
|
||||||
|
*/
|
||||||
|
public @Nullable String checkSession(LoginSessionData loginSessionData, @Nullable Long pollTimeoutMillis)
|
||||||
|
throws MiCloudException {
|
||||||
|
logger.debug("Xiaomi login step 2 checking session");
|
||||||
|
if (loginSessionData.expiresAt < System.currentTimeMillis()) {
|
||||||
|
throw new MiCloudException("Long polling timed out");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String pollUrl = loginSessionData.longPollingUrl;
|
||||||
|
Request request = httpClient.newRequest(pollUrl).method("GET").timeout(
|
||||||
|
pollTimeoutMillis != null ? pollTimeoutMillis : 30 * REQUEST_TIMEOUT_SECONDS * 1000,
|
||||||
|
TimeUnit.MILLISECONDS);
|
||||||
|
|
||||||
|
ContentResponse response = request.send();
|
||||||
|
if (response.getStatus() != 200) {
|
||||||
|
logger.debug("Xiaomi login step 2: Non-200 response: {}", response.getStatus());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json = response.getContentAsString().replace("&&&START&&&", "");
|
||||||
|
JsonObject responseJson = GSON.fromJson(json, JsonObject.class);
|
||||||
|
if (responseJson == null) {
|
||||||
|
throw new MiCloudException("Invalid response during QR code login session check");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userId = CloudUtil.getJsonString(responseJson, "userId", "");
|
||||||
|
this.ssecurity = CloudUtil.getJsonString(responseJson, "ssecurity", "");
|
||||||
|
String cuserId = CloudUtil.getJsonString(responseJson, "cUserId", "");
|
||||||
|
String passToken = CloudUtil.getJsonString(responseJson, "passToken", "");
|
||||||
|
String location = CloudUtil.getJsonString(responseJson, "location", "");
|
||||||
|
String code = CloudUtil.getJsonString(responseJson, "code", "");
|
||||||
|
if (logger.isTraceEnabled()) {
|
||||||
|
logger.trace("Xiaomi login ssecurity: {}", this.ssecurity);
|
||||||
|
logger.trace("Xiaomi login userId: {}", this.userId);
|
||||||
|
logger.trace("Xiaomi login cUserId: {}", cuserId);
|
||||||
|
logger.trace("Xiaomi login passToken: {}", passToken);
|
||||||
|
logger.trace("Xiaomi login location: {}", location);
|
||||||
|
logger.trace("Xiaomi login code: {}", code);
|
||||||
|
}
|
||||||
|
if (location.isEmpty()) {
|
||||||
|
throw new MiCloudException("Error getting login location URL. Return code: " + code);
|
||||||
|
}
|
||||||
|
return location;
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
logger.debug("Xiaomi login step 2 Long polling timed out");
|
||||||
|
return null;
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.debug("Xiaomi login step 2 interrupted");
|
||||||
|
return null;
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
logger.debug("Xiaomi login step 2 Long polling requests failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2026 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.miio.internal.cloud;
|
||||||
|
|
||||||
|
import java.net.CookieStore;
|
||||||
|
import java.net.HttpCookie;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
|
import org.eclipse.jetty.client.api.ContentResponse;
|
||||||
|
import org.eclipse.jetty.client.api.Request;
|
||||||
|
import org.eclipse.jetty.client.util.FormContentProvider;
|
||||||
|
import org.eclipse.jetty.http.HttpField;
|
||||||
|
import org.eclipse.jetty.http.HttpFields;
|
||||||
|
import org.eclipse.jetty.http.HttpHeader;
|
||||||
|
import org.eclipse.jetty.http.HttpMethod;
|
||||||
|
import org.eclipse.jetty.http.HttpStatus;
|
||||||
|
import org.eclipse.jetty.util.Fields;
|
||||||
|
import org.eclipse.jetty.util.HttpCookieStore;
|
||||||
|
import org.openhab.binding.miio.internal.MiIoCrypto;
|
||||||
|
import org.openhab.binding.miio.internal.MiIoCryptoException;
|
||||||
|
import org.openhab.binding.miio.internal.Utils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import com.google.gson.JsonSyntaxException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link MiCloudUserIdLoginConnector} class is used for connecting to the Xiaomi cloud access
|
||||||
|
*
|
||||||
|
* @author Marcel Verpaalen - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class MiCloudUserIdLoginConnector extends MiCloudConnector {
|
||||||
|
private static final String BROWSER_USERAGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0";
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(MiCloudUserIdLoginConnector.class);
|
||||||
|
private @Nullable Request fa;
|
||||||
|
|
||||||
|
public MiCloudUserIdLoginConnector(String username, String password, HttpClient httpClient)
|
||||||
|
throws MiCloudException {
|
||||||
|
super(username, password, httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MiCloudUserIdLoginConnector(@Nullable String username, @Nullable String password, HttpClient httpClient,
|
||||||
|
@Nullable String clientId, @Nullable String userId, @Nullable String serviceToken,
|
||||||
|
@Nullable String ssecurity) throws MiCloudException {
|
||||||
|
super(username, password, httpClient, clientId, userId, serviceToken, ssecurity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean login() {
|
||||||
|
logger.debug("client Id={}", clientId);
|
||||||
|
return login("");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean login(String captchaResponse) {
|
||||||
|
if (!userId.isEmpty() && !serviceToken.isEmpty()) {
|
||||||
|
updateLoginState(CloudLoginState.ONLINE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!checkCredentials()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!userId.isEmpty() && !serviceToken.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.debug("Xiaomi cloud login with userid {}", username);
|
||||||
|
try {
|
||||||
|
if (loginRequest(captchaResponse)) {
|
||||||
|
loginFailedCounter = 0;
|
||||||
|
} else {
|
||||||
|
loginFailedCounter++;
|
||||||
|
logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
|
||||||
|
}
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
|
||||||
|
loginFailedCounter++;
|
||||||
|
serviceToken = "";
|
||||||
|
loginFailedCounterCheck();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean loginRequest(String captchaResponse) throws MiCloudException {
|
||||||
|
try {
|
||||||
|
startClient();
|
||||||
|
|
||||||
|
String sign = this.sign.isEmpty() ? loginStep1() : this.sign;
|
||||||
|
String location;
|
||||||
|
if (!sign.startsWith("http")) {
|
||||||
|
this.sign = sign;
|
||||||
|
location = loginStep2(sign, captchaResponse);
|
||||||
|
} else {
|
||||||
|
location = sign; // seems we already have login location
|
||||||
|
}
|
||||||
|
final ContentResponse responseStep3 = loginStep3(location);
|
||||||
|
switch (responseStep3.getStatus()) {
|
||||||
|
case HttpStatus.FORBIDDEN_403:
|
||||||
|
updateLoginState(CloudLoginState.ACCESS_DENIED);
|
||||||
|
throw new MiCloudException("Access denied. Did you set the correct api-key and/or username?");
|
||||||
|
case HttpStatus.OK_200:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
|
||||||
|
responseStep3.getReason(), responseStep3.getContentAsString());
|
||||||
|
throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new MiCloudException("Xiaomi cloud login interrupted", e);
|
||||||
|
} catch (TimeoutException | ExecutionException e) {
|
||||||
|
throw new MiCloudException("Cannot login to Xiaomi cloud: " + e.getMessage(), e);
|
||||||
|
} catch (MiIoCryptoException e) {
|
||||||
|
throw new MiCloudException("Error decrypting. Cannot login to Xiaomi cloud: " + e.getMessage(), e);
|
||||||
|
} catch (MalformedURLException | JsonParseException e) {
|
||||||
|
throw new MiCloudException("Error getting login URL. Cannot login to Xiaomi cloud: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
|
||||||
|
logger.trace("Xiaomi Login step 1");
|
||||||
|
String url = "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true";
|
||||||
|
Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.agent(USERAGENT);
|
||||||
|
request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
|
||||||
|
request.cookie(new HttpCookie("userId", !this.userId.isEmpty() ? this.userId : this.username));
|
||||||
|
|
||||||
|
final ContentResponse responseStep1 = request.send();
|
||||||
|
final String content = responseStep1.getContentAsString();
|
||||||
|
logger.trace("Xiaomi Login step 1 content response= {}", content);
|
||||||
|
logger.trace("Xiaomi Login step 1 response = {}", responseStep1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonElement resp = JsonParser.parseString(CloudUtil.parseJson(content));
|
||||||
|
CloudLogin1DTO jsonResp = GSON.fromJson(resp, CloudLogin1DTO.class);
|
||||||
|
|
||||||
|
if (jsonResp == null) {
|
||||||
|
throw new MiCloudException("Xiaomi Login step 1: Failed to parse response");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sign = jsonResp.getSign();
|
||||||
|
if (!sign.isEmpty()) {
|
||||||
|
logger.trace("Xiaomi Login step 1 sign = {}", sign);
|
||||||
|
return sign;
|
||||||
|
} else {
|
||||||
|
logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
|
||||||
|
throw new MiCloudException("Xiaomi Login _sign missing. Maybe still has login cookie.");
|
||||||
|
}
|
||||||
|
} catch (JsonParseException | IllegalStateException | ClassCastException e) {
|
||||||
|
throw new MiCloudException("Error getting login sign. Cannot parse response: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loginStep2(String sign, String captchaResponse) throws MiIoCryptoException, InterruptedException,
|
||||||
|
TimeoutException, ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
|
||||||
|
logger.trace("Xiaomi Login step 2");
|
||||||
|
String url = "https://account.xiaomi.com/pass/serviceLoginAuth2";
|
||||||
|
|
||||||
|
Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.agent(USERAGENT);
|
||||||
|
request.method(HttpMethod.POST);
|
||||||
|
|
||||||
|
Fields fields = new Fields();
|
||||||
|
fields.put("sid", "xiaomiio");
|
||||||
|
fields.put("hash", Utils.getHex(MiIoCrypto.md5(password.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
|
||||||
|
fields.put("callback", "https://sts.api.io.mi.com/sts");
|
||||||
|
fields.put("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue");
|
||||||
|
fields.put("user", username);
|
||||||
|
if (!sign.isEmpty()) {
|
||||||
|
fields.put("_sign", sign);
|
||||||
|
}
|
||||||
|
if (!captchaResponse.isEmpty()) {
|
||||||
|
fields.put("captCode", captchaResponse);
|
||||||
|
logger.debug("login with captcha response {}", Utils.obfuscateToken(captchaResponse));
|
||||||
|
} else {
|
||||||
|
logger.debug("login step 2 without captcha response");
|
||||||
|
}
|
||||||
|
fields.put("_json", "true");
|
||||||
|
|
||||||
|
request.content(new FormContentProvider(fields));
|
||||||
|
final ContentResponse responseStep2 = request.send();
|
||||||
|
|
||||||
|
final String content2 = responseStep2.getContentAsString();
|
||||||
|
logger.trace("Xiaomi login step 2 response = {}", responseStep2);
|
||||||
|
logger.trace("Xiaomi login step 2 content = {}", content2);
|
||||||
|
|
||||||
|
JsonElement resp2 = JsonParser.parseString(CloudUtil.parseJson(content2));
|
||||||
|
CloudLoginDTO jsonResp = GSON.fromJson(resp2, CloudLoginDTO.class);
|
||||||
|
|
||||||
|
if (jsonResp == null) {
|
||||||
|
throw new MiCloudException("Error getting login details from step 2: " + content2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ssecurity = jsonResp.getSsecurity();
|
||||||
|
userId = jsonResp.getUserId();
|
||||||
|
String cUserId = jsonResp.getcUserId();
|
||||||
|
String passToken = jsonResp.getPassToken();
|
||||||
|
String location = jsonResp.getLocation();
|
||||||
|
String code = jsonResp.getCode();
|
||||||
|
String captchaUrl = jsonResp.getCaptchaUrl();
|
||||||
|
String callbackUrl = jsonResp.getCallback();
|
||||||
|
String notificationUrl = jsonResp.getNotificationUrl();
|
||||||
|
Integer securityStatus = jsonResp.getSecurityStatus();
|
||||||
|
Integer pwd = jsonResp.getPwd();
|
||||||
|
|
||||||
|
logger.trace("Xiaomi login ssecurity = {}", ssecurity);
|
||||||
|
logger.trace("Xiaomi login userId = {}", userId);
|
||||||
|
logger.trace("Xiaomi login cUserId = {}", cUserId);
|
||||||
|
logger.trace("Xiaomi login passToken = {}", passToken);
|
||||||
|
logger.trace("Xiaomi login location = {}", location);
|
||||||
|
logger.trace("Xiaomi login code = {}", code);
|
||||||
|
logger.trace("Xiaomi login captcha URL = {}", captchaUrl);
|
||||||
|
logger.trace("Xiaomi login callbackUrl = {}", callbackUrl);
|
||||||
|
|
||||||
|
if ("87001".equals(code)) {
|
||||||
|
logger.debug("Xiaomi Cloud Step2 failed captcha: {}", CloudUtil.parseJson(content2));
|
||||||
|
updateLoginState(CloudLoginState.CAPTCHA_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (securityStatus != 0) {
|
||||||
|
logger.debug("Xiaomi Cloud Step2 response: {}", CloudUtil.parseJson(content2));
|
||||||
|
logger.debug(
|
||||||
|
"""
|
||||||
|
Xiaomi Login code: {}
|
||||||
|
SecurityStatus: {}
|
||||||
|
Pwd code: {}
|
||||||
|
Location login URL: {}
|
||||||
|
In case of login issues check userId/password details are correct.
|
||||||
|
If login details are correct, try to login using browser from the openHAB ip using the browser. Alternatively try to complete login with above URL.\
|
||||||
|
""",
|
||||||
|
code, securityStatus, pwd, location);
|
||||||
|
|
||||||
|
if (!notificationUrl.isEmpty()) {
|
||||||
|
logger.info("Click submit and get token. Then enter the token in OH:\r\n{} ", notificationUrl);
|
||||||
|
get2factory(notificationUrl);
|
||||||
|
updateLoginState(CloudLoginState.AWAITING_2FA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!captchaUrl.isEmpty()) {
|
||||||
|
updateLoginState(CloudLoginState.AWAITING_CAPTCHA);
|
||||||
|
downloadCaptcha(captchaUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logger.isTraceEnabled()) {
|
||||||
|
dumpCookies(url, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!location.isEmpty()) {
|
||||||
|
return location;
|
||||||
|
} else {
|
||||||
|
if (loginState.equals(CloudLoginState.AWAITING_2FA) || loginState.equals(CloudLoginState.AWAITING_CAPTCHA)
|
||||||
|
|| loginState.equals(CloudLoginState.CAPTCHA_FAILED)) {
|
||||||
|
logger.debug("Retry with new captcha/2fa");
|
||||||
|
}
|
||||||
|
throw new MiCloudException("Error getting login location URL. Return code: " + code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void get2factory(String url) {
|
||||||
|
CookieStore cookieStore = httpClient.getCookieStore();
|
||||||
|
try {
|
||||||
|
httpClient.setCookieStore(new HttpCookieStore.Empty());
|
||||||
|
logger.debug("Trying to request code from {}", url);
|
||||||
|
|
||||||
|
Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.agent(BROWSER_USERAGENT);
|
||||||
|
request.method(HttpMethod.GET);
|
||||||
|
debugRequest(request);
|
||||||
|
|
||||||
|
String newurl = url.replace("authStart", "list");
|
||||||
|
request = httpClient.newRequest(newurl).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.agent(BROWSER_USERAGENT);
|
||||||
|
debugRequest(request);
|
||||||
|
|
||||||
|
request = httpClient.newRequest("https://account.xiaomi.com/identity/auth/verifyEmail?_flag=8&_json=true")
|
||||||
|
.timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.agent(BROWSER_USERAGENT);
|
||||||
|
debugRequest(request);
|
||||||
|
|
||||||
|
long ms = java.time.Instant.now().toEpochMilli();
|
||||||
|
String mailticket = "https://account.xiaomi.com/identity/auth/sendEmailTicket?_dc=" + ms;
|
||||||
|
request = httpClient.newRequest(mailticket).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.agent(BROWSER_USERAGENT);
|
||||||
|
request.method(HttpMethod.POST);
|
||||||
|
|
||||||
|
Fields fields = new Fields();
|
||||||
|
fields.put("retry", "0");
|
||||||
|
fields.put("icode", "");
|
||||||
|
fields.put("_json", "true");
|
||||||
|
request.content(new FormContentProvider(fields));
|
||||||
|
debugRequest(request);
|
||||||
|
|
||||||
|
String verifyticket = "https://account.xiaomi.com/identity/auth/verifyEmail?_dc=" + ms;
|
||||||
|
request = httpClient.newRequest(verifyticket).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.method(HttpMethod.POST);
|
||||||
|
this.fa = request;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.warn("2FA setup interrupted: {}", e.getMessage());
|
||||||
|
} catch (TimeoutException | ExecutionException e) {
|
||||||
|
logger.warn("Error requesting 2FA code: {}", e.getMessage(), e);
|
||||||
|
} finally {
|
||||||
|
httpClient.setCookieStore(cookieStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the 2FA response code.
|
||||||
|
*
|
||||||
|
* @param faCode The 2FA code entered by the user
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void faResponse(String faCode) {
|
||||||
|
try {
|
||||||
|
Request fa = this.fa;
|
||||||
|
if (fa == null) {
|
||||||
|
logger.warn("2FA request not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Fields fields = new Fields();
|
||||||
|
fields.put("_flag", "0");
|
||||||
|
fields.put("trust", "false");
|
||||||
|
fields.put("_json", "true");
|
||||||
|
fields.put("ticket", faCode.trim());
|
||||||
|
fa.content(new FormContentProvider(fields));
|
||||||
|
|
||||||
|
ContentResponse result = debugRequest(fa);
|
||||||
|
String resultContent = result.getContentAsString();
|
||||||
|
String jsonContent = CloudUtil.parseJson(resultContent);
|
||||||
|
JsonElement resultJson = JsonParser.parseString(jsonContent);
|
||||||
|
|
||||||
|
if (!resultJson.isJsonObject()) {
|
||||||
|
logger.warn("2FA response is not a valid JSON object: {}", resultContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject jsonObject = resultJson.getAsJsonObject();
|
||||||
|
String location = CloudUtil.getJsonString(jsonObject, "location", "");
|
||||||
|
|
||||||
|
if (location.isEmpty()) {
|
||||||
|
String code = CloudUtil.getJsonString(jsonObject, "code", "");
|
||||||
|
String description = CloudUtil.getJsonString(jsonObject, "description", "");
|
||||||
|
logger.warn("2FA failed - code: {}, description: {}", code, description);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Request request = httpClient.newRequest(location).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
request.method(HttpMethod.POST);
|
||||||
|
ContentResponse endresult = debugRequest(request);
|
||||||
|
|
||||||
|
HttpFields headers = endresult.getHeaders();
|
||||||
|
location = extractLocationFromHeaders(headers);
|
||||||
|
|
||||||
|
if (!location.isEmpty()) {
|
||||||
|
final ContentResponse response = loginStep3(location);
|
||||||
|
logger.trace("Xiaomi login step 3 status = {}", response.getStatus());
|
||||||
|
logger.trace("Xiaomi login step 3 response = {}", response);
|
||||||
|
logger.trace("Xiaomi login step 3 header = {}", response.getHeaders().toString());
|
||||||
|
logger.trace("Xiaomi login step 3 content = {}", response.getContentAsString());
|
||||||
|
} else {
|
||||||
|
logger.warn("2FA completed but no redirect location found");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.warn("2FA response interrupted: {}", e.getMessage());
|
||||||
|
} catch (TimeoutException | ExecutionException | MalformedURLException e) {
|
||||||
|
logger.warn("Error in 2FA code: {}", e.getMessage(), e);
|
||||||
|
} catch (JsonParseException e) {
|
||||||
|
logger.warn("Error parsing 2FA response: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the location URL from HTTP response headers.
|
||||||
|
*
|
||||||
|
* @param headers The HTTP response headers
|
||||||
|
* @return The location URL or empty string if not found
|
||||||
|
*/
|
||||||
|
private String extractLocationFromHeaders(HttpFields headers) {
|
||||||
|
for (HttpField header : headers) {
|
||||||
|
String headerName = header.getName();
|
||||||
|
if ("location".equalsIgnoreCase(headerName)) {
|
||||||
|
String value = header.getValue();
|
||||||
|
logger.trace("Found location header: {}", value);
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
if ("extension-pragma".equalsIgnoreCase(headerName)) {
|
||||||
|
logger.trace("Found extension-pragma header: {}", header.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads and processes a captcha image.
|
||||||
|
*
|
||||||
|
* @param captchaURL The URL of the captcha image
|
||||||
|
*/
|
||||||
|
protected void downloadCaptcha(String captchaURL) {
|
||||||
|
String imgUrl = captchaURL.startsWith("/") ? "https://account.xiaomi.com" + captchaURL : captchaURL;
|
||||||
|
|
||||||
|
if (imgUrl.isEmpty() || !imgUrl.startsWith("http")) {
|
||||||
|
logger.debug("Captcha URL invalid: {}", captchaURL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Downloading captcha from: {}", imgUrl);
|
||||||
|
try {
|
||||||
|
fetchAndInformImage(imgUrl, REQUEST_TIMEOUT_SECONDS, "miio-captcha-");
|
||||||
|
} catch (MiCloudException e) {
|
||||||
|
logger.debug("Error while downloading captcha: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-32
@@ -20,7 +20,6 @@ import java.net.DatagramSocket;
|
|||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.SocketException;
|
import java.net.SocketException;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Dictionary;
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -43,8 +42,6 @@ import org.openhab.core.config.discovery.DiscoveryService;
|
|||||||
import org.openhab.core.net.NetUtil;
|
import org.openhab.core.net.NetUtil;
|
||||||
import org.openhab.core.thing.ThingTypeUID;
|
import org.openhab.core.thing.ThingTypeUID;
|
||||||
import org.openhab.core.thing.ThingUID;
|
import org.openhab.core.thing.ThingUID;
|
||||||
import org.osgi.service.cm.Configuration;
|
|
||||||
import org.osgi.service.cm.ConfigurationAdmin;
|
|
||||||
import org.osgi.service.component.annotations.Activate;
|
import org.osgi.service.component.annotations.Activate;
|
||||||
import org.osgi.service.component.annotations.Component;
|
import org.osgi.service.component.annotations.Component;
|
||||||
import org.osgi.service.component.annotations.Reference;
|
import org.osgi.service.component.annotations.Reference;
|
||||||
@@ -78,42 +75,16 @@ public class MiIoDiscovery extends AbstractDiscoveryService {
|
|||||||
private final Logger logger = LoggerFactory.getLogger(MiIoDiscovery.class);
|
private final Logger logger = LoggerFactory.getLogger(MiIoDiscovery.class);
|
||||||
private final CloudConnector cloudConnector;
|
private final CloudConnector cloudConnector;
|
||||||
private Map<String, String> cloudDevices = new ConcurrentHashMap<>();
|
private Map<String, String> cloudDevices = new ConcurrentHashMap<>();
|
||||||
private @Nullable Configuration miioConfig;
|
|
||||||
|
|
||||||
@Activate
|
@Activate
|
||||||
public MiIoDiscovery(@Reference CloudConnector cloudConnector, @Reference ConfigurationAdmin configAdmin)
|
public MiIoDiscovery(@Reference CloudConnector cloudConnector) throws IllegalArgumentException {
|
||||||
throws IllegalArgumentException {
|
|
||||||
super(DISCOVERY_TIME);
|
super(DISCOVERY_TIME);
|
||||||
this.cloudConnector = cloudConnector;
|
this.cloudConnector = cloudConnector;
|
||||||
try {
|
|
||||||
miioConfig = configAdmin.getConfiguration("binding.miio");
|
|
||||||
} catch (IOException | SecurityException e) {
|
|
||||||
logger.debug("Error getting configuration: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getCloudDiscoveryMode() {
|
private String getCloudDiscoveryMode() {
|
||||||
final Configuration miioConfig = this.miioConfig;
|
String cloudDiscoveryModeConfig = cloudConnector.getCloudDiscoveryMode().toLowerCase();
|
||||||
if (miioConfig != null) {
|
return Set.of(SUPPORTED, ALL).contains(cloudDiscoveryModeConfig) ? cloudDiscoveryModeConfig : DISABLED;
|
||||||
try {
|
|
||||||
Dictionary<String, @Nullable Object> properties = miioConfig.getProperties();
|
|
||||||
String cloudDiscoveryModeConfig;
|
|
||||||
if (properties == null) {
|
|
||||||
cloudDiscoveryModeConfig = DISABLED;
|
|
||||||
} else {
|
|
||||||
cloudDiscoveryModeConfig = (String) properties.get("cloudDiscoveryMode");
|
|
||||||
if (cloudDiscoveryModeConfig == null) {
|
|
||||||
cloudDiscoveryModeConfig = DISABLED;
|
|
||||||
} else {
|
|
||||||
cloudDiscoveryModeConfig = cloudDiscoveryModeConfig.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Set.of(SUPPORTED, ALL).contains(cloudDiscoveryModeConfig) ? cloudDiscoveryModeConfig : DISABLED;
|
|
||||||
} catch (ClassCastException | SecurityException e) {
|
|
||||||
logger.debug("Error getting cloud discovery configuration: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DISABLED;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -124,6 +95,8 @@ public class MiIoDiscovery extends AbstractDiscoveryService {
|
|||||||
@Override
|
@Override
|
||||||
protected void startBackgroundDiscovery() {
|
protected void startBackgroundDiscovery() {
|
||||||
logger.debug("Start Xiaomi Mi IO background discovery with cloudDiscoveryMode: {}", getCloudDiscoveryMode());
|
logger.debug("Start Xiaomi Mi IO background discovery with cloudDiscoveryMode: {}", getCloudDiscoveryMode());
|
||||||
|
// TODO: align if this is a desired pattern
|
||||||
|
// cloudThingDiscovery();
|
||||||
final @Nullable ScheduledFuture<?> miIoDiscoveryJob = this.miIoDiscoveryJob;
|
final @Nullable ScheduledFuture<?> miIoDiscoveryJob = this.miIoDiscoveryJob;
|
||||||
if (miIoDiscoveryJob == null || miIoDiscoveryJob.isCancelled()) {
|
if (miIoDiscoveryJob == null || miIoDiscoveryJob.isCancelled()) {
|
||||||
this.miIoDiscoveryJob = scheduler.scheduleWithFixedDelay(this::discover, 0, SEARCH_INTERVAL,
|
this.miIoDiscoveryJob = scheduler.scheduleWithFixedDelay(this::discover, 0, SEARCH_INTERVAL,
|
||||||
@@ -242,6 +215,18 @@ public class MiIoDiscovery extends AbstractDiscoveryService {
|
|||||||
submitDiscovery(ip, token, id, label, model, country, isOnline, parent);
|
submitDiscovery(ip, token, id, label, model, country, isOnline, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposes the Cloud Connector thing for discovery so users do not need to be aware and manually add it.
|
||||||
|
* TODO: Align if this is a desired pattern.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* private void cloudThingDiscovery() {
|
||||||
|
* logger.debug("Proposing Cloud Connector thing for discovery.");
|
||||||
|
* thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_CLOUD, "cloudConnector"))
|
||||||
|
* .withLabel("Xiaomi Cloud Connector").build());
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
private void submitDiscovery(String ip, String token, String id, String label, String model, String country,
|
private void submitDiscovery(String ip, String token, String id, String label, String model, String country,
|
||||||
boolean isOnline, String parent) {
|
boolean isOnline, String parent) {
|
||||||
ThingUID uid;
|
ThingUID uid;
|
||||||
|
|||||||
+345
@@ -0,0 +1,345 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2010-2026 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.miio.internal.handler;
|
||||||
|
|
||||||
|
import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Map;
|
||||||
|
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.miio.internal.Utils;
|
||||||
|
import org.openhab.binding.miio.internal.cloud.CloudConnector;
|
||||||
|
import org.openhab.binding.miio.internal.cloud.CloudLoginListener;
|
||||||
|
import org.openhab.binding.miio.internal.cloud.MiCloudConnector.CloudLoginMode;
|
||||||
|
import org.openhab.binding.miio.internal.cloud.MiCloudConnector.CloudLoginState;
|
||||||
|
import org.openhab.core.config.core.Configuration;
|
||||||
|
import org.openhab.core.io.net.http.HttpUtil;
|
||||||
|
import org.openhab.core.library.types.OnOffType;
|
||||||
|
import org.openhab.core.library.types.RawType;
|
||||||
|
import org.openhab.core.library.types.StringType;
|
||||||
|
import org.openhab.core.thing.ChannelUID;
|
||||||
|
import org.openhab.core.thing.Thing;
|
||||||
|
import org.openhab.core.thing.ThingStatus;
|
||||||
|
import org.openhab.core.thing.ThingStatusDetail;
|
||||||
|
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||||
|
import org.openhab.core.types.Command;
|
||||||
|
import org.openhab.core.types.UnDefType;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link MiIoCloudThingHandler} is responsible for handling commands, which are
|
||||||
|
* sent to one of the channels.
|
||||||
|
*
|
||||||
|
* @author Marcel Verpaalen - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class MiIoCloudThingHandler extends BaseThingHandler implements CloudLoginListener {
|
||||||
|
|
||||||
|
private static final int CLIENT_ID_LENGTH = 6;
|
||||||
|
private static final String DEFAULT_COUNTRY = "ru,us,tw,sg,cn,de,i2";
|
||||||
|
private static final long MIN_RETRY_INTERVAL_MS = 30_000L;
|
||||||
|
|
||||||
|
private static final RawType HOURGLASS_IMAGE = loadStatusImage("cloud_initiating.svg");
|
||||||
|
private static final RawType HAPPY_IMAGE = loadStatusImage("cloud_online.svg");
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(MiIoCloudThingHandler.class);
|
||||||
|
private @Nullable ScheduledFuture<?> loginFuture;
|
||||||
|
private @Nullable ScheduledFuture<?> propertyUpdateFuture;
|
||||||
|
private @Nullable ScheduledFuture<?> tokenReadFuture;
|
||||||
|
|
||||||
|
private final CloudConnector cloudConnector;
|
||||||
|
|
||||||
|
private String username = "";
|
||||||
|
private String password = "";
|
||||||
|
private String country = "";
|
||||||
|
private volatile String userId = "";
|
||||||
|
private volatile String clientId = "";
|
||||||
|
private volatile String ssecurity = "";
|
||||||
|
private volatile String serviceToken = "";
|
||||||
|
private CloudLoginMode loginMethod = CloudLoginMode.QRCODE;
|
||||||
|
private String cloudDiscoveryMode = "disabled";
|
||||||
|
private volatile long lastLoginTriggerTime = 0;
|
||||||
|
|
||||||
|
public MiIoCloudThingHandler(Thing thing, CloudConnector cloudConnector) {
|
||||||
|
super(thing);
|
||||||
|
this.cloudConnector = cloudConnector;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() {
|
||||||
|
logger.debug("Initializing Cloud Connector handler '{}'", getThing().getUID());
|
||||||
|
|
||||||
|
Configuration properties = getThing().getConfiguration();
|
||||||
|
this.username = getConfigString(properties, CONFIG_USERNAME);
|
||||||
|
this.password = getConfigString(properties, CONFIG_PASSWORD);
|
||||||
|
String country = getConfigString(properties, CONFIG_COUNTRY);
|
||||||
|
this.country = country.isBlank() ? DEFAULT_COUNTRY : country;
|
||||||
|
this.clientId = getConfigString(properties, CONFIG_CLIENT_ID);
|
||||||
|
this.userId = getConfigString(properties, CONFIG_USER_ID);
|
||||||
|
this.serviceToken = getConfigString(properties, CONFIG_SERVICE_TOKEN);
|
||||||
|
this.ssecurity = getConfigString(properties, CONFIG_SSECURITY);
|
||||||
|
String loginMethodvalue = getConfigString(properties, CONFIG_LOGIN_METHOD);
|
||||||
|
this.loginMethod = "PASSWORD".equalsIgnoreCase(loginMethodvalue) ? CloudLoginMode.PASSWORD
|
||||||
|
: CloudLoginMode.QRCODE;
|
||||||
|
this.cloudDiscoveryMode = getConfigString(properties, CONFIG_CLOUD_DISCOVERY_MODE);
|
||||||
|
|
||||||
|
validateAndGenerateClientId();
|
||||||
|
setupCloudConnector();
|
||||||
|
loginFuture = scheduler.schedule(this::connectorLogin, 1, TimeUnit.SECONDS);
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.cloud-initiating");
|
||||||
|
updateState(CHANNEL_LOGIN_IMAGE, HOURGLASS_IMAGE);
|
||||||
|
updateState(CHANNEL_TWOFA, UnDefType.NULL);
|
||||||
|
updateState(CHANNEL_TRIGGER_LOGIN, OnOffType.OFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getConfigString(Configuration config, String key) {
|
||||||
|
String value = (String) config.get(key);
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectorLogin() {
|
||||||
|
cloudConnector.isConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateAndGenerateClientId() {
|
||||||
|
final String clientId = this.clientId;
|
||||||
|
if (clientId.isBlank() || clientId.length() != CLIENT_ID_LENGTH) {
|
||||||
|
logger.debug("Client Id is empty, generating new one");
|
||||||
|
this.clientId = generateRandomClientId();
|
||||||
|
propertyUpdateFuture = scheduler.schedule(
|
||||||
|
() -> updateThingProperties(Map.of(CONFIG_CLIENT_ID, this.clientId)), 1, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRandomClientId() {
|
||||||
|
return new SecureRandom().ints(97, 123).limit(CLIENT_ID_LENGTH)
|
||||||
|
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasValidCredentials() {
|
||||||
|
return !userId.isBlank() && !serviceToken.isBlank() && !ssecurity.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupCloudConnector() {
|
||||||
|
cloudConnector.setCredentials(username, password, country, clientId, userId, serviceToken, ssecurity);
|
||||||
|
cloudConnector.setLoginMode(hasValidCredentials() ? CloudLoginMode.TOKEN : loginMethod);
|
||||||
|
cloudConnector.setCloudDiscoveryMode(cloudDiscoveryMode);
|
||||||
|
cloudConnector.registerListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLoginImage(byte[] captcha) {
|
||||||
|
logger.debug("QR / Captcha received with length: {}", captcha.length);
|
||||||
|
if (loginMethod == CloudLoginMode.QRCODE) {
|
||||||
|
logger.info("QR code is ready for scanning. Open the '{}' channel in openHAB and scan the QR code.",
|
||||||
|
CHANNEL_LOGIN_IMAGE);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"Captcha image is available. Check the '{}' channel and submit the response via the '{}' channel.",
|
||||||
|
CHANNEL_LOGIN_IMAGE, CHANNEL_CAPTCHA_RESPONSE);
|
||||||
|
}
|
||||||
|
String mimeType = HttpUtil.guessContentTypeFromData(captcha);
|
||||||
|
updateState(CHANNEL_LOGIN_IMAGE, new RawType(captcha, mimeType));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||||
|
logger.debug("Received command for channel {}, with command: {}", channelUID.getId(), command);
|
||||||
|
switch (channelUID.getId()) {
|
||||||
|
case CHANNEL_CAPTCHA_RESPONSE:
|
||||||
|
if (!(command instanceof StringType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug("Submit captcha response {}", Utils.obfuscateToken(command.toString()));
|
||||||
|
cloudConnector.submitCaptcha(command.toString());
|
||||||
|
break;
|
||||||
|
case CHANNEL_TWOFA:
|
||||||
|
if (!(command instanceof StringType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug("Received 2-factor authentication response {}", Utils.obfuscateToken(command.toString()));
|
||||||
|
cloudConnector.submit2FA(command.toString());
|
||||||
|
break;
|
||||||
|
case CHANNEL_TRIGGER_LOGIN:
|
||||||
|
if (OnOffType.ON.equals(command)) {
|
||||||
|
updateState(CHANNEL_TRIGGER_LOGIN, OnOffType.OFF);
|
||||||
|
triggerNewLoginSequence();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.info("Cannot handle channel {}", channelUID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateThingProperties(Map<String, String> updatedProperties) {
|
||||||
|
Configuration currentConfig = getThing().getConfiguration();
|
||||||
|
boolean hasChanges = updatedProperties.entrySet().stream()
|
||||||
|
.anyMatch(e -> !e.getValue().equals(currentConfig.get(e.getKey())));
|
||||||
|
if (!hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug("Updating thing properties for {}", getThing().getUID());
|
||||||
|
Configuration config = editConfiguration();
|
||||||
|
updatedProperties.forEach(config::put);
|
||||||
|
updateConfiguration(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatusUpdated(CloudLoginState loginState, String status) {
|
||||||
|
logger.debug("Cloud login state updated: {} - {}", loginState, status);
|
||||||
|
if (loginState == CloudLoginState.ONLINE) {
|
||||||
|
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
||||||
|
updateStatus(ThingStatus.ONLINE);
|
||||||
|
}
|
||||||
|
updateState(CHANNEL_TWOFA, UnDefType.NULL);
|
||||||
|
updateState(CHANNEL_LOGIN_IMAGE, HAPPY_IMAGE);
|
||||||
|
// Read back tokens after login() completes; schedule with a short delay because
|
||||||
|
// CloudConnector.login() syncs its token fields after login() returns (i.e., after this callback fires)
|
||||||
|
ScheduledFuture<?> existingTokenFuture = tokenReadFuture;
|
||||||
|
if (existingTokenFuture != null && !existingTokenFuture.isDone()) {
|
||||||
|
existingTokenFuture.cancel(true);
|
||||||
|
}
|
||||||
|
tokenReadFuture = scheduler.schedule(() -> {
|
||||||
|
String newUserId = cloudConnector.getUserId();
|
||||||
|
String newServiceToken = cloudConnector.getServiceToken();
|
||||||
|
String newSsecurity = cloudConnector.getSsecurity();
|
||||||
|
if (!newServiceToken.equals(this.serviceToken) || !newUserId.equals(this.userId)
|
||||||
|
|| !newSsecurity.equals(this.ssecurity)) {
|
||||||
|
this.userId = newUserId;
|
||||||
|
this.serviceToken = newServiceToken;
|
||||||
|
this.ssecurity = newSsecurity;
|
||||||
|
updateThingProperties(Map.of(CONFIG_USER_ID, this.userId, CONFIG_SERVICE_TOKEN, this.serviceToken,
|
||||||
|
CONFIG_SSECURITY, this.ssecurity));
|
||||||
|
}
|
||||||
|
}, 1, TimeUnit.SECONDS);
|
||||||
|
} else if (loginState == CloudLoginState.ACCESS_DENIED && hasValidCredentials()) {
|
||||||
|
// Stored token was rejected — clear it and fall back to QR or password login
|
||||||
|
logger.info("Stored token rejected by Xiaomi cloud. Clearing token and retrying with {} login",
|
||||||
|
loginMethod);
|
||||||
|
this.userId = "";
|
||||||
|
this.serviceToken = "";
|
||||||
|
this.ssecurity = "";
|
||||||
|
ScheduledFuture<?> existingLoginFuture = loginFuture;
|
||||||
|
if (existingLoginFuture != null && !existingLoginFuture.isDone()) {
|
||||||
|
existingLoginFuture.cancel(true);
|
||||||
|
}
|
||||||
|
loginFuture = scheduler.schedule(() -> {
|
||||||
|
updateThingProperties(Map.of(CONFIG_USER_ID, "", CONFIG_SERVICE_TOKEN, "", CONFIG_SSECURITY, ""));
|
||||||
|
cloudConnector.setCredentials(username, password, country, clientId, "", "", "");
|
||||||
|
cloudConnector.setLoginMode(loginMethod);
|
||||||
|
cloudConnector.setCloudDiscoveryMode(cloudDiscoveryMode);
|
||||||
|
connectorLogin();
|
||||||
|
}, 2, TimeUnit.SECONDS);
|
||||||
|
} else if (loginState == CloudLoginState.AWAITING_2FA) {
|
||||||
|
logger.info("Two-factor authentication required. Please submit the 2FA code via the '{}' channel.",
|
||||||
|
CHANNEL_TWOFA);
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/offline.cloud-awaiting-2fa");
|
||||||
|
} else if (loginState == CloudLoginState.AWAITING_CAPTCHA) {
|
||||||
|
logger.info(
|
||||||
|
"Captcha is required. Check the '{}' channel image and submit the response via the '{}' channel.",
|
||||||
|
CHANNEL_LOGIN_IMAGE, CHANNEL_CAPTCHA_RESPONSE);
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/offline.cloud-awaiting-captcha");
|
||||||
|
} else if (loginState == CloudLoginState.AWAITING_QRLOGIN) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/offline.cloud-awaiting-qr");
|
||||||
|
} else if (loginState == CloudLoginState.ACCESS_DENIED) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/offline.cloud-access-denied");
|
||||||
|
} else if (loginState == CloudLoginState.CAPTCHA_FAILED) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/offline.cloud-captcha-failed");
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/offline.cloud-comm-error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose() {
|
||||||
|
logger.debug("Disposing Xiaomi Mi IO Cloudconnector handler '{}'", getThing().getUID());
|
||||||
|
ScheduledFuture<?> future = loginFuture;
|
||||||
|
if (future != null) {
|
||||||
|
future.cancel(true);
|
||||||
|
loginFuture = null;
|
||||||
|
}
|
||||||
|
ScheduledFuture<?> propFuture = propertyUpdateFuture;
|
||||||
|
if (propFuture != null) {
|
||||||
|
propFuture.cancel(true);
|
||||||
|
propertyUpdateFuture = null;
|
||||||
|
}
|
||||||
|
ScheduledFuture<?> tokenFuture = tokenReadFuture;
|
||||||
|
if (tokenFuture != null) {
|
||||||
|
tokenFuture.cancel(true);
|
||||||
|
tokenReadFuture = null;
|
||||||
|
}
|
||||||
|
cloudConnector.unregisterListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels any in-progress login, resets the connector state, and schedules a fresh login
|
||||||
|
* sequence. Has a 30-second cooldown to prevent rapid re-triggering.
|
||||||
|
*/
|
||||||
|
private void triggerNewLoginSequence() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastLoginTriggerTime < MIN_RETRY_INTERVAL_MS) {
|
||||||
|
logger.debug("Login re-trigger ignored: another login attempt was started less than {} seconds ago.",
|
||||||
|
MIN_RETRY_INTERVAL_MS / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastLoginTriggerTime = now;
|
||||||
|
logger.info("Re-triggering login sequence for Cloud Connector '{}'", getThing().getUID());
|
||||||
|
|
||||||
|
ScheduledFuture<?> existingLoginFuture = loginFuture;
|
||||||
|
if (existingLoginFuture != null) {
|
||||||
|
existingLoginFuture.cancel(true);
|
||||||
|
loginFuture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stored token credentials from handler state so the connector uses interactive login
|
||||||
|
this.userId = "";
|
||||||
|
this.serviceToken = "";
|
||||||
|
this.ssecurity = "";
|
||||||
|
|
||||||
|
// Stop any in-progress connector and reset the login state
|
||||||
|
cloudConnector.resetLogin();
|
||||||
|
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.cloud-retrigger");
|
||||||
|
updateState(CHANNEL_LOGIN_IMAGE, HOURGLASS_IMAGE);
|
||||||
|
updateState(CHANNEL_TWOFA, UnDefType.NULL);
|
||||||
|
|
||||||
|
loginFuture = scheduler.schedule(() -> {
|
||||||
|
updateThingProperties(Map.of(CONFIG_USER_ID, "", CONFIG_SERVICE_TOKEN, "", CONFIG_SSECURITY, ""));
|
||||||
|
cloudConnector.setCredentials(username, password, country, clientId, "", "", "");
|
||||||
|
cloudConnector.setLoginMode(loginMethod);
|
||||||
|
cloudConnector.setCloudDiscoveryMode(cloudDiscoveryMode);
|
||||||
|
connectorLogin();
|
||||||
|
}, 1, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RawType loadStatusImage(String filename) {
|
||||||
|
try (InputStream stream = MiIoCloudThingHandler.class.getResourceAsStream("/images/" + filename)) {
|
||||||
|
if (stream != null) {
|
||||||
|
return new RawType(stream.readAllBytes(), "image/svg+xml");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// fall through to empty image — must never throw here or the class fails to initialise
|
||||||
|
}
|
||||||
|
return new RawType(new byte[0], "image/svg+xml");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,26 +11,12 @@
|
|||||||
<config-description>
|
<config-description>
|
||||||
<parameter name="username" type="text" required="false">
|
<parameter name="username" type="text" required="false">
|
||||||
<label>Xiaomi cloud username</label>
|
<label>Xiaomi cloud username</label>
|
||||||
<description>Xiaomi cloud username. Typically your email</description>
|
<description>Deprecated: configure via the Cloud Connector thing instead. Xiaomi cloud username. Typically your
|
||||||
|
email</description>
|
||||||
</parameter>
|
</parameter>
|
||||||
<parameter name="password" type="text" required="false">
|
<parameter name="password" type="text" required="false">
|
||||||
<label>Xiaomi cloud password</label>
|
<label>Xiaomi cloud password</label>
|
||||||
</parameter>
|
<description>Deprecated: configure via the Cloud Connector thing instead.</description>
|
||||||
<parameter name="country" type="text" required="false">
|
|
||||||
<label>Xiaomi server country</label>
|
|
||||||
<description>Xiaomi server country(s) (e.g. sg,de). Separate multiple servers with comma. Leave empty for all. See
|
|
||||||
binding readme for country to server mapping</description>
|
|
||||||
</parameter>
|
|
||||||
<parameter name="cloudDiscoveryMode" type="text" required="false">
|
|
||||||
<default>disabled</default>
|
|
||||||
<label>Cloud Discovery Mode</label>
|
|
||||||
<description>Allow for discovery via the cloud. This may be used for devices that are not on the same network as
|
|
||||||
OpenHAB server</description>
|
|
||||||
<options>
|
|
||||||
<option value="disabled">Local discovery only (Default)</option>
|
|
||||||
<option value="supportedOnly">Discover online supported devices from Xiaomi cloud</option>
|
|
||||||
<option value="all">Discover all on & offline devices from Xiaomi cloud (advanced, see readme for usage)</option>
|
|
||||||
</options>
|
|
||||||
</parameter>
|
</parameter>
|
||||||
</config-description>
|
</config-description>
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,16 @@ addon.miio.description = Binding for Xiaomi wifi devices (Mi IO protocol) which
|
|||||||
|
|
||||||
# add-on config
|
# add-on config
|
||||||
|
|
||||||
addon.config.miio.cloudDiscoveryMode.label = Cloud Discovery Mode
|
|
||||||
addon.config.miio.cloudDiscoveryMode.description = Allow for discovery via the cloud. This may be used for devices that are not on the same network as OpenHAB server
|
|
||||||
addon.config.miio.cloudDiscoveryMode.option.disabled = Local discovery only (Default)
|
|
||||||
addon.config.miio.cloudDiscoveryMode.option.supportedOnly = Discover online supported devices from Xiaomi cloud
|
|
||||||
addon.config.miio.cloudDiscoveryMode.option.all = Discover all on & offline devices from Xiaomi cloud (advanced, see readme for usage)
|
|
||||||
addon.config.miio.country.label = Xiaomi server country
|
|
||||||
addon.config.miio.country.description = Xiaomi server country(s) (e.g. sg,de). Separate multiple servers with comma. Leave empty for all. See binding readme for country to server mapping
|
|
||||||
addon.config.miio.password.label = Xiaomi cloud password
|
addon.config.miio.password.label = Xiaomi cloud password
|
||||||
|
addon.config.miio.password.description = Deprecated: configure via the Cloud Connector thing instead.
|
||||||
addon.config.miio.username.label = Xiaomi cloud username
|
addon.config.miio.username.label = Xiaomi cloud username
|
||||||
addon.config.miio.username.description = Xiaomi cloud username. Typically your email
|
addon.config.miio.username.description = Deprecated: configure via the Cloud Connector thing instead. Xiaomi cloud username. Typically your email
|
||||||
|
|
||||||
# thing types
|
# thing types
|
||||||
|
|
||||||
thing-type.miio.basic.label = Xiaomi Mi Basic Device
|
thing-type.miio.basic.label = Xiaomi Mi Basic Device
|
||||||
|
thing-type.miio.cloud.label = Cloud Connector Thing
|
||||||
|
thing-type.miio.cloud.description = Cloud Connector Thing supporting captcha and 2FA authentication
|
||||||
thing-type.miio.gateway.label = Xiaomi Mi Gateway
|
thing-type.miio.gateway.label = Xiaomi Mi Gateway
|
||||||
thing-type.miio.generic.label = Xiaomi Mi Device
|
thing-type.miio.generic.label = Xiaomi Mi Device
|
||||||
thing-type.miio.lumi.label = Xiaomi Mi Lumi Device
|
thing-type.miio.lumi.label = Xiaomi Mi Lumi Device
|
||||||
@@ -27,6 +23,27 @@ thing-type.miio.vacuum.label = Xiaomi Robot Vacuum
|
|||||||
|
|
||||||
# thing types config
|
# thing types config
|
||||||
|
|
||||||
|
thing-type.config.miio.cloud.client-id.label = Cloud Client ID
|
||||||
|
thing-type.config.miio.cloud.client-id.description = Xiaomi cloud client ID identifies this client
|
||||||
|
thing-type.config.miio.cloud.cloud-discovery-mode.label = Cloud Discovery Mode
|
||||||
|
thing-type.config.miio.cloud.cloud-discovery-mode.description = Allow for discovery via the cloud. This may be used for devices that are not on the same network as openHAB server
|
||||||
|
thing-type.config.miio.cloud.cloud-discovery-mode.option.disabled = Local Only (Default)
|
||||||
|
thing-type.config.miio.cloud.cloud-discovery-mode.option.supported = Online Supported
|
||||||
|
thing-type.config.miio.cloud.cloud-discovery-mode.option.all = All Devices (Advanced)
|
||||||
|
thing-type.config.miio.cloud.country.label = Xiaomi Server Country
|
||||||
|
thing-type.config.miio.cloud.country.description = Xiaomi server country(s) (e.g. sg,de). Separate multiple servers with comma. Leave empty for all. See binding readme for country to server mapping
|
||||||
|
thing-type.config.miio.cloud.login-method.label = Cloud Login Method
|
||||||
|
thing-type.config.miio.cloud.login-method.option.QRCODE = QR Code (Default)
|
||||||
|
thing-type.config.miio.cloud.login-method.option.PASSWORD = Password Login
|
||||||
|
thing-type.config.miio.cloud.password.label = Xiaomi Cloud Password
|
||||||
|
thing-type.config.miio.cloud.service-token.label = Cloud Service Token
|
||||||
|
thing-type.config.miio.cloud.service-token.description = Xiaomi cloud service token, automatically retrieved at login
|
||||||
|
thing-type.config.miio.cloud.ssecurity.label = Cloud Ssecurity
|
||||||
|
thing-type.config.miio.cloud.ssecurity.description = Xiaomi ssecurity value, automatically retrieved at login
|
||||||
|
thing-type.config.miio.cloud.user-id.label = Cloud User ID
|
||||||
|
thing-type.config.miio.cloud.user-id.description = Xiaomi cloud user ID, automatically retrieved at login
|
||||||
|
thing-type.config.miio.cloud.username.label = Xiaomi Cloud Username
|
||||||
|
thing-type.config.miio.cloud.username.description = Xiaomi cloud username. Typically your email
|
||||||
thing-type.config.miio.config.cloudServer.label = Cloud Server Country Code
|
thing-type.config.miio.config.cloudServer.label = Cloud Server Country Code
|
||||||
thing-type.config.miio.config.cloudServer.description = Country code (2 characters) of the Xiaomi cloud server. See binding documentation for mapping of the country to cloud server
|
thing-type.config.miio.config.cloudServer.description = Country code (2 characters) of the Xiaomi cloud server. See binding documentation for mapping of the country to cloud server
|
||||||
thing-type.config.miio.config.communication.label = Communication Method
|
thing-type.config.miio.config.communication.label = Communication Method
|
||||||
@@ -73,6 +90,8 @@ channel-group-type.miio.status.label = Status
|
|||||||
# channel types
|
# channel types
|
||||||
|
|
||||||
channel-type.miio.bssid.label = BSSID
|
channel-type.miio.bssid.label = BSSID
|
||||||
|
channel-type.miio.captcha-response.label = Captcha Response
|
||||||
|
channel-type.miio.captcha-response.description = Enter in this channel the value as seen in the captcha.
|
||||||
channel-type.miio.carpet_mode.label = Carpet Mode
|
channel-type.miio.carpet_mode.label = Carpet Mode
|
||||||
channel-type.miio.clean_area.label = Cleaning Area
|
channel-type.miio.clean_area.label = Cleaning Area
|
||||||
channel-type.miio.clean_mop_start.label = Start Mop Wash
|
channel-type.miio.clean_mop_start.label = Start Mop Wash
|
||||||
@@ -142,6 +161,8 @@ channel-type.miio.led_status.label = LED Status
|
|||||||
channel-type.miio.life.label = Life
|
channel-type.miio.life.label = Life
|
||||||
channel-type.miio.location.label = Generic Location Channel
|
channel-type.miio.location.label = Generic Location Channel
|
||||||
channel-type.miio.lock_status.label = Lock Status
|
channel-type.miio.lock_status.label = Lock Status
|
||||||
|
channel-type.miio.login-image.label = Login/Captcha Image
|
||||||
|
channel-type.miio.login-image.description = Scan QR code for login or Enter the captcha value in the captcha response channel.
|
||||||
channel-type.miio.main_brush_percent.label = Main Brush Remaining
|
channel-type.miio.main_brush_percent.label = Main Brush Remaining
|
||||||
channel-type.miio.main_brush_time.label = Main Brush Time Until Replacement
|
channel-type.miio.main_brush_time.label = Main Brush Time Until Replacement
|
||||||
channel-type.miio.map.label = Cleaning Map
|
channel-type.miio.map.label = Cleaning Map
|
||||||
@@ -180,6 +201,10 @@ channel-type.miio.testmiot.description = Create experimental support for MIOT pr
|
|||||||
channel-type.miio.total_clean_area.label = Total Cleaning Area
|
channel-type.miio.total_clean_area.label = Total Cleaning Area
|
||||||
channel-type.miio.total_clean_count.label = Total Cleanings
|
channel-type.miio.total_clean_count.label = Total Cleanings
|
||||||
channel-type.miio.total_clean_time.label = Total Cleaning Time
|
channel-type.miio.total_clean_time.label = Total Cleaning Time
|
||||||
|
channel-type.miio.trigger-login.label = Trigger Login
|
||||||
|
channel-type.miio.trigger-login.description = Turn ON to cancel any in-progress login and start a fresh login sequence (new QR code or captcha). Resets automatically. Has a 30-second cooldown to prevent rapid re-triggering.
|
||||||
|
channel-type.miio.two-fa.label = Two-Factor Response
|
||||||
|
channel-type.miio.two-fa.description = Enter the 2 factor authentication code received by email.
|
||||||
channel-type.miio.vacuum.label = Vacuum On/Off
|
channel-type.miio.vacuum.label = Vacuum On/Off
|
||||||
channel-type.miio.water_box_carriage_status.label = Water Box Carriage State
|
channel-type.miio.water_box_carriage_status.label = Water Box Carriage State
|
||||||
channel-type.miio.water_box_mode.label = Water Box Mode
|
channel-type.miio.water_box_mode.label = Water Box Mode
|
||||||
@@ -190,3 +215,11 @@ channel-type.miio.water_box_status.label = Water Box State
|
|||||||
offline.config-error-ip = IP address required. Configure IP address
|
offline.config-error-ip = IP address required. Configure IP address
|
||||||
offline.config-error-token = Token required. Configure token
|
offline.config-error-token = Token required. Configure token
|
||||||
offline.config-error-cloud = Cloud communication requires defined deviceId in the config
|
offline.config-error-cloud = Cloud communication requires defined deviceId in the config
|
||||||
|
offline.cloud-initiating = Initiating cloud logon
|
||||||
|
offline.cloud-retrigger = Re-triggering cloud login sequence
|
||||||
|
offline.cloud-awaiting-qr = QR code ready. Scan QR via the login-image channel.
|
||||||
|
offline.cloud-awaiting-2fa = Two-factor authentication required. Enter code via the two-fa channel.
|
||||||
|
offline.cloud-awaiting-captcha = Captcha verification required. Check the login-image channel and submit via captcha-response.
|
||||||
|
offline.cloud-captcha-failed = Captcha verification failed. Check the login-image channel and try again.
|
||||||
|
offline.cloud-access-denied = Authentication denied by Xiaomi cloud. Check credentials or use the trigger-login channel to re-authenticate.
|
||||||
|
offline.cloud-comm-error = Cloud communication error. Check the log for details.
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="miio"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<thing-type id="cloud">
|
||||||
|
<label>Cloud Connector Thing</label>
|
||||||
|
<description>Cloud Connector Thing supporting captcha and 2FA authentication</description>
|
||||||
|
<semantic-equipment-tag>WebService</semantic-equipment-tag>
|
||||||
|
|
||||||
|
<channels>
|
||||||
|
<channel id="login-image" typeId="login-image"/>
|
||||||
|
<channel id="captcha-response" typeId="captcha-response"/>
|
||||||
|
<channel id="two-fa" typeId="two-fa"/>
|
||||||
|
<channel id="trigger-login" typeId="trigger-login"/>
|
||||||
|
</channels>
|
||||||
|
|
||||||
|
<config-description>
|
||||||
|
<parameter name="username" type="text" required="false">
|
||||||
|
<label>Xiaomi Cloud Username</label>
|
||||||
|
<description>Xiaomi cloud username. Typically your email</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="password" type="text" required="false">
|
||||||
|
<label>Xiaomi Cloud Password</label>
|
||||||
|
<context>password</context>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="login-method" type="text" required="false">
|
||||||
|
<label>Cloud Login Method</label>
|
||||||
|
<default>QRCODE</default>
|
||||||
|
<options>
|
||||||
|
<option value="QRCODE">QR Code (Default)</option>
|
||||||
|
<option value="PASSWORD">Password Login</option>
|
||||||
|
</options>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="country" type="text" required="false">
|
||||||
|
<label>Xiaomi Server Country</label>
|
||||||
|
<description>Xiaomi server country(s) (e.g. sg,de). Separate multiple servers with comma. Leave empty for all. See
|
||||||
|
binding readme for country to server mapping</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="cloud-discovery-mode" type="text" required="false">
|
||||||
|
<default>disabled</default>
|
||||||
|
<label>Cloud Discovery Mode</label>
|
||||||
|
<description>Allow for discovery via the cloud. This may be used for devices that are not on the same network as
|
||||||
|
openHAB server</description>
|
||||||
|
<options>
|
||||||
|
<option value="disabled">Local Only (Default)</option>
|
||||||
|
<option value="supported">Online Supported</option>
|
||||||
|
<option value="all">All Devices (Advanced)</option>
|
||||||
|
</options>
|
||||||
|
</parameter>
|
||||||
|
|
||||||
|
<parameter name="user-id" type="text" required="false">
|
||||||
|
<label>Cloud User ID</label>
|
||||||
|
<description>Xiaomi cloud user ID, automatically retrieved at login</description>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
|
||||||
|
<parameter name="client-id" type="text" required="false">
|
||||||
|
<label>Cloud Client ID</label>
|
||||||
|
<description>Xiaomi cloud client ID identifies this client</description>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
|
||||||
|
<parameter name="service-token" type="text" required="false">
|
||||||
|
<label>Cloud Service Token</label>
|
||||||
|
<description>Xiaomi cloud service token, automatically retrieved at login</description>
|
||||||
|
<context>password</context>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
|
||||||
|
<parameter name="ssecurity" type="text" required="false">
|
||||||
|
<label>Cloud Ssecurity</label>
|
||||||
|
<description>Xiaomi ssecurity value, automatically retrieved at login</description>
|
||||||
|
<context>password</context>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
|
||||||
|
</config-description>
|
||||||
|
</thing-type>
|
||||||
|
|
||||||
|
<channel-type id="login-image">
|
||||||
|
<item-type>Image</item-type>
|
||||||
|
<label>Login/Captcha Image</label>
|
||||||
|
<description>Scan QR code for login or Enter the captcha value in the captcha response channel.</description>
|
||||||
|
<category>settings</category>
|
||||||
|
</channel-type>
|
||||||
|
|
||||||
|
<channel-type id="captcha-response">
|
||||||
|
<item-type>String</item-type>
|
||||||
|
<label>Captcha Response</label>
|
||||||
|
<description>Enter in this channel the value as seen in the captcha.</description>
|
||||||
|
<category>settings</category>
|
||||||
|
</channel-type>
|
||||||
|
|
||||||
|
<channel-type id="two-fa">
|
||||||
|
<item-type>String</item-type>
|
||||||
|
<label>Two-Factor Response</label>
|
||||||
|
<description>Enter the 2 factor authentication code received by email.</description>
|
||||||
|
<category>settings</category>
|
||||||
|
</channel-type>
|
||||||
|
|
||||||
|
<channel-type id="trigger-login">
|
||||||
|
<item-type>Switch</item-type>
|
||||||
|
<label>Trigger Login</label>
|
||||||
|
<description>Turn ON to cancel any in-progress login and start a fresh login sequence (new QR code or captcha). Resets
|
||||||
|
automatically. Has a 30-second cooldown to prevent rapid re-triggering.</description>
|
||||||
|
<category>settings</category>
|
||||||
|
</channel-type>
|
||||||
|
|
||||||
|
</thing:thing-descriptions>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="48" fill="#FFA500"/>
|
||||||
|
<polygon points="20,18 80,18 50,50" fill="white"/>
|
||||||
|
<polygon points="20,82 80,82 50,50" fill="white"/>
|
||||||
|
<line x1="18" y1="18" x2="82" y2="18" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="18" y1="82" x2="82" y2="82" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="48" fill="#FFD700"/>
|
||||||
|
<circle cx="35" cy="37" r="7" fill="#333333"/>
|
||||||
|
<circle cx="65" cy="37" r="7" fill="#333333"/>
|
||||||
|
<path d="M 28,68 Q 50,86 72,68" stroke="#333333" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 323 B |
+6
-6
@@ -34,20 +34,20 @@ public class UtilsTest {
|
|||||||
tokenString = "6614";
|
tokenString = "6614";
|
||||||
assertEquals("6614", Utils.obfuscateToken(tokenString));
|
assertEquals("6614", Utils.obfuscateToken(tokenString));
|
||||||
|
|
||||||
tokenString = "66147986";
|
tokenString = "6614****";
|
||||||
assertEquals("66147986", Utils.obfuscateToken(tokenString));
|
assertEquals("6614****", Utils.obfuscateToken(tokenString));
|
||||||
|
|
||||||
tokenString = "6614798643fe781563c1";
|
tokenString = "6614798643fe781563c1";
|
||||||
assertEquals("66147986XXXXXXXXXXXX", Utils.obfuscateToken(tokenString));
|
assertEquals("6614****************", Utils.obfuscateToken(tokenString));
|
||||||
|
|
||||||
tokenString = "6614798643fe781563c1eebe";
|
tokenString = "6614798643fe781563c1eebe";
|
||||||
assertEquals("66147986XXXXXXXXXXXXXXXX", Utils.obfuscateToken(tokenString));
|
assertEquals("6614********************", Utils.obfuscateToken(tokenString));
|
||||||
|
|
||||||
tokenString = "6614798643fe781563c1eebeda22479a";
|
tokenString = "6614798643fe781563c1eebeda22479a";
|
||||||
assertEquals("66147986XXXXXXXXXXXXXXXXda22479a", Utils.obfuscateToken(tokenString));
|
assertEquals("6614********************da22479a", Utils.obfuscateToken(tokenString));
|
||||||
|
|
||||||
tokenString = "6614798643fe781563c1eebeda22479a6614798643fe781563c1eebeda22479a";
|
tokenString = "6614798643fe781563c1eebeda22479a6614798643fe781563c1eebeda22479a";
|
||||||
assertEquals("66147986XXXXXXXXXXXXXXXXda22479a6614798643fe781563c1eebeda22479a",
|
assertEquals("6614********************da22479a6614798643fe781563c1eebeda22479a",
|
||||||
Utils.obfuscateToken(tokenString));
|
Utils.obfuscateToken(tokenString));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user