From 13976de829f08bc18d34b94c02768d9bae599f56 Mon Sep 17 00:00:00 2001 From: Marcel Verpaalen Date: Sat, 28 Mar 2020 18:38:39 +0100 Subject: [PATCH 01/10] [miio] Squash/rebase Xiaomi cloud logon and map download [miio] WIP cloudlogon to get device tokens and map [miio] save server responses [miio] cloudstuff cleanup [miio] Cloud - more updates map download [miio] WIP get vacuum map from cloud [miio] WIP get tokens from cloud [miio] WIP output vacuum image [miio] first working version with map and tokens from cloud [miio] improvements map downloads [miio] improve map decoding [miio] finalized map drawing logic [miio] 2nd alpha version cloud+map funcionality [miio] fixing spotbugs issues [miio] more spot bug issues resolving [miio] improved map reading [miio] map version 1.1 [miio] cleanup many notnullbydefault issues alpha release3 [miio] cleaning cloudconnector [miio] alpha release 3 [mio] cached logons [miio] small cleanup [miio] minor fix text drawing [miio] alpha 4 [miio] new models added [miio] Improve path color [miio] generic county request [miio] mapviewer cleanup [miio] log levels adjustment [miio] update readme Signed-off-by: Marcel Verpaalen marcel@verpaalen.com --- .../org.openhab.binding.miio/README.base.md | 64 ++- bundles/org.openhab.binding.miio/README.md | 135 +++-- .../internal/MiIoBindingConfiguration.java | 1 + .../miio/internal/MiIoBindingConstants.java | 2 + .../binding/miio/internal/MiIoDevices.java | 15 +- .../miio/internal/MiIoHandlerFactory.java | 24 +- .../miio/internal/cloud/CloudConnector.java | 240 +++++++++ .../miio/internal/cloud/CloudCrypto.java | 69 +++ .../miio/internal/cloud/CloudUtil.java | 172 +++++++ .../miio/internal/cloud/MiCloudConnector.java | 468 ++++++++++++++++++ .../miio/internal/cloud/MiCloudException.java | 37 ++ .../internal/discovery/MiIoDiscovery.java | 19 + .../internal/handler/MiIoVacuumHandler.java | 87 +++- .../miio/internal/robot/RRMapDraw.java | 368 ++++++++++++++ .../miio/internal/robot/RRMapFileParser.java | 415 ++++++++++++++++ .../resources/ESH-INF/binding/binding.xml | 22 +- .../main/resources/ESH-INF/config/config.xml | 4 + .../resources/ESH-INF/thing/vacuumThing.xml | 6 + .../binding/miio/internal/RoboMapViewer.java | 330 ++++++++++++ 19 files changed, 2394 insertions(+), 84 deletions(-) create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java create mode 100644 bundles/org.openhab.binding.miio/src/test/java/org/openhab/binding/miio/internal/RoboMapViewer.java diff --git a/bundles/org.openhab.binding.miio/README.base.md b/bundles/org.openhab.binding.miio/README.base.md index d6fc70ac6bd25..41438904fc6c5 100644 --- a/bundles/org.openhab.binding.miio/README.base.md +++ b/bundles/org.openhab.binding.miio/README.base.md @@ -16,28 +16,6 @@ The following things types are available: | miio:basic | For several basic devices like yeelights, airpurifiers. Channels and commands are determined by database configuration | | miio:unsupported | For experimenting with other devices which use the Mi IO protocol | -## Mi IO Devices - -!!!devices - -# Advanced: Unsupported devices - -Newer devices may not yet be supported. -However, many devices share large similarties with existing devices. -The binding allows to try/test if your new device is working with database files of older devices as well. -For this, first remove your unsupported thing. Manually add a miio:basic thing. -Besides the regular configuration (like ip address, token) the modelId needs to be provided. -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 for the modelId of similar devices. - -# Advanced: adding local database files to support new devices - -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. -The config/misc/miio (e.g. in Linux `/opt/openhab2/config/misc/miio/`) is scanned for database files and will be used for your devices. -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. - # Discovery The binding has 2 methods for discovering devices. Depending on your network setup and the device model, your device may be discovered by one or both methods. If both methods discover your device, 2 discovery results may be in your inbox for the same device. @@ -49,6 +27,10 @@ Accept only one of the 2 discovery results, the alternate one can further be ign ## Tokens The binding needs a token from the Xiaomi Mi Device in order to be able to control it. +The binding can retrieve the needed tokens from the Xiaomi cloud. Go to the binding config page and enter your cloud username and password. The server(s) to which your devices are connected need to be entered as well. Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. + +## Tokens without cloud access + 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. @@ -64,7 +46,7 @@ Note. The Xiaomi devices change the token when inclusion is done. Hence if you g ## Binding Configuration -No binding configuration is required. +No binding configuration is required. However to enable cloud functionality enter Xiaomi username, password and server(s) ## Thing Configuration @@ -82,11 +64,32 @@ However, for devices that are unsupported, you may override the value and try to | refreshInterval | integer | false | Refresh interval for refreshing the data in seconds. (0=disabled) | | timeout | integer | false | Timeout time in milliseconds | - ### Example Thing file `Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="0326xxxx" ]` +## Mi IO Devices + +!!!devices + +# Advanced: Unsupported devices + +Newer devices may not yet be supported. +However, many devices share large similarties with existing devices. +The binding allows to try/test if your new device is working with database files of older devices as well. +For this, first remove your unsupported thing. Manually add a miio:basic thing. +Besides the regular configuration (like ip address, token) the modelId needs to be provided. +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 for the modelId of similar devices. + +# Advanced: adding local database files to support new devices + +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. +The config/misc/miio (e.g. in Linux `/opt/openhab2/config/misc/miio/`) is scanned for database files and will be used for your devices. +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. + ## Channels Depending on the device, different channels are available. @@ -116,6 +119,7 @@ Group gVacStat "Status Details" (gVac) Group gVacCons "Consumables Usage" (gVac) Group gVacDND "Do Not Disturb Settings" (gVac) Group gVacHist "Cleaning History" (gVac) +Group gVacLast "Last Cleaning Details" (gVac) String actionControl "Vacuum Control" {channel="miio:vacuum:034F0E45:actions#control" } String actionCommand "Vacuum Command" {channel="miio:vacuum:034F0E45:actions#commands" } @@ -141,7 +145,17 @@ String dndEnd "DND End Time [%s]" (gVacDND) {channel="miio:vacuu Number historyArea "Total Cleaned Area [%1.0fm²]" (gVacHist) {channel="miio:vacuum:034F0E45:history#total_clean_area"} String historyTime "Total Clean Time [%s]" (gVacHist) {channel="miio:vacuum:034F0E45:history#total_clean_time"} Number historyCount "Total # Cleanings [%1.0f]" (gVacHist) {channel="miio:vacuum:034F0E45:history#total_clean_count"} -``` +String lastStart "Last Cleaning Start time [%s]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_start_time"} +String lastEnd "Last Cleaning End time [%s]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_end_time"} +Number lastArea "Last Cleaned Area [%1.0fm²]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_area"} +Number lastTime "Last Clean Time [%1.0f']" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_duration"} +Number lastError "Error [%s]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_error" } +Switch lastCompleted "Last Cleaning Completed" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_finish" } + + +Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"} +``` +Note: cleaning map is only available with cloud access. !!!itemFileExamples diff --git a/bundles/org.openhab.binding.miio/README.md b/bundles/org.openhab.binding.miio/README.md index ddd3058f9a290..7f88db7855a95 100644 --- a/bundles/org.openhab.binding.miio/README.md +++ b/bundles/org.openhab.binding.miio/README.md @@ -16,6 +16,58 @@ The following things types are available: | miio:basic | For several basic devices like yeelights, airpurifiers. Channels and commands are determined by database configuration | | miio:unsupported | For experimenting with other devices which use the Mi IO protocol | +# Discovery + +The binding has 2 methods for discovering devices. Depending on your network setup and the device model, your device may be discovered by one or both methods. If both methods discover your device, 2 discovery results may be in your inbox for the same device. + +The mDNS discovery method will discover your device type, but will not discover a (required) token. +The basic discovery will not discovery the type, but will discover a token for models that support it. +Accept only one of the 2 discovery results, the alternate one can further be ignored. + +## Tokens + +The binding needs a token from the Xiaomi Mi Device in order to be able to control it. +The binding can retrieve the needed tokens from the Xiaomi cloud. Go to the binding config page and enter your cloud username and password. The server(s) to which your devices are connected need to be entered as well. Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. + +## Tokens without cloud access + +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. + +The easiest way to obtain tokens is to browse through log files of the Mi Home app version 5.4.49 for Android. +It seems that version was released with debug messages turned on by mistake. +An APK file with the old version can be easily found using one of the popular web search engines. +After downgrading use a file browser to navigate to directory SmartHome/logs/plug_DeviceManager, then open the most recent file and search for the token. When finished, use Google Play to get the most recent version back. + +For iPhone, use an un-encrypted iTunes-Backup and unpack it and use a sqlite tool to view the files in it: +Then search in "RAW, com.xiaomi.home," for "USERID_mihome.sqlite" and look for the 32-digit-token or 96 digit encrypted token. + +Note. The Xiaomi devices change the token when inclusion is done. Hence if you get your token after reset and than include it with the Mi Home app, the token will change. + +## Binding Configuration + +No binding configuration is required. However to enable cloud functionality enter Xiaomi username, password and server(s) + +## Thing Configuration + +Each Xiaomi device (thing) needs the IP address and token configured to be able to communicate. See discovery for details. +Optional configuration is the refresh interval and the deviceID. Note that the deviceID is automatically retrieved when it is left blank. +The configuration for model is automatically retrieved from the device in normal operation. +However, for devices that are unsupported, you may override the value and try to use a model string from a similar device to experimentally use your device with the binding. + +| Parameter | Type | Required | Description | +|-----------------|---------|----------|-------------------------------------------------------------------| +| host | text | true | Device IP address | +| token | text | true | Token for communication (in Hex) | +| deviceId | text | true | Device ID number for communication (in Hex) | +| model | text | false | Device model string, used to determine the subtype | +| refreshInterval | integer | false | Refresh interval for refreshing the data in seconds. (0=disabled) | +| timeout | integer | false | Timeout time in milliseconds | + +### Example Thing file + +`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="0326xxxx" ]` + ## Mi IO Devices | Device | ThingType | Device Model | Supported | Remark | @@ -99,7 +151,20 @@ The following things types are available: | Mi Xiaowa Vacuum c1 | miio:vacuum | [roborock.vacuum.c1](#roborock-vacuum-c1) | Yes | | | Mi Robot Vacuum v2 | miio:vacuum | [roborock.vacuum.s5](#roborock-vacuum-s5) | Yes | | | Mi Robot Vacuum 1S | miio:vacuum | [roborock.vacuum.m1s](#roborock-vacuum-m1s) | Yes | | -| Roborock Vacuum S6 | miio:vacuum | [roborock.vacuum.s6](#roborock-vacuum-s6) | Yes | | +| Mi Robot Vacuum S4 | miio:vacuum | [roborock.vacuum.s4](#roborock-vacuum-s4) | Yes | | +| Roborock Vacuum S4v2 | miio:vacuum | [roborock.vacuum.s4v2](#roborock-vacuum-s4v2) | Yes | | +| Roborock Vacuum T6 | miio:vacuum | [roborock.vacuum.t6](#roborock-vacuum-t6) | Yes | | +| Roborock Vacuum T6 v2 | miio:vacuum | [roborock.vacuum.t6v2](#roborock-vacuum-t6v2) | Yes | | +| Roborock Vacuum T6 v3 | miio:vacuum | [roborock.vacuum.t6v3](#roborock-vacuum-t6v3) | Yes | | +| Roborock Vacuum T4 | miio:vacuum | [roborock.vacuum.t4](#roborock-vacuum-t4) | Yes | | +| Roborock Vacuum T4 v2 | miio:vacuum | [roborock.vacuum.t4v2](#roborock-vacuum-t4v2) | Yes | | +| Roborock Vacuum T4 v3 | miio:vacuum | [roborock.vacuum.t4v3](#roborock-vacuum-t4v3) | Yes | | +| Roborock Vacuum T7 | miio:vacuum | [roborock.vacuum.t7](#roborock-vacuum-t7) | Yes | | +| Roborock Vacuum T7 v2 | miio:vacuum | [roborock.vacuum.t7v2](#roborock-vacuum-t7v2) | Yes | | +| Roborock Vacuum T7 v3 | miio:vacuum | [roborock.vacuum.t7v3](#roborock-vacuum-t7v3) | Yes | | +| Roborock Vacuum T7p | miio:vacuum | [roborock.vacuum.t7p](#roborock-vacuum-t7p) | Yes | | +| Roborock Vacuum T7 v2 | miio:vacuum | [roborock.vacuum.t7pv2](#roborock-vacuum-t7pv2) | Yes | | +| Roborock Vacuum T7 v3 | miio:vacuum | [roborock.vacuum.t7pv3](#roborock-vacuum-t7pv3) | Yes | | | Roborock Vacuum S5 Max | miio:vacuum | [roborock.vacuum.s5e](#roborock-vacuum-s5e) | Yes | | | Roborock Vacuum S6 | miio:vacuum | [rockrobo.vacuum.s6](#rockrobo-vacuum-s6) | Yes | | | Rockrobo Xiaowa Vacuum v2 | miio:unsupported | roborock.vacuum.e2 | No | | @@ -162,55 +227,6 @@ The config/misc/miio (e.g. in Linux `/opt/openhab2/config/misc/miio/`) is scanne 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. -# Discovery - -The binding has 2 methods for discovering devices. Depending on your network setup and the device model, your device may be discovered by one or both methods. If both methods discover your device, 2 discovery results may be in your inbox for the same device. - -The mDNS discovery method will discover your device type, but will not discover a (required) token. -The basic discovery will not discovery the type, but will discover a token for models that support it. -Accept only one of the 2 discovery results, the alternate one can further be ignored. - -## Tokens - -The binding needs a token from the Xiaomi Mi Device in order to be able to control it. -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. - -The easiest way to obtain tokens is to browse through log files of the Mi Home app version 5.4.49 for Android. -It seems that version was released with debug messages turned on by mistake. -An APK file with the old version can be easily found using one of the popular web search engines. -After downgrading use a file browser to navigate to directory SmartHome/logs/plug_DeviceManager, then open the most recent file and search for the token. When finished, use Google Play to get the most recent version back. - -For iPhone, use an un-encrypted iTunes-Backup and unpack it and use a sqlite tool to view the files in it: -Then search in "RAW, com.xiaomi.home," for "USERID_mihome.sqlite" and look for the 32-digit-token or 96 digit encrypted token. - -Note. The Xiaomi devices change the token when inclusion is done. Hence if you get your token after reset and than include it with the Mi Home app, the token will change. - -## Binding Configuration - -No binding configuration is required. - -## Thing Configuration - -Each Xiaomi device (thing) needs the IP address and token configured to be able to communicate. See discovery for details. -Optional configuration is the refresh interval and the deviceID. Note that the deviceID is automatically retrieved when it is left blank. -The configuration for model is automatically retrieved from the device in normal operation. -However, for devices that are unsupported, you may override the value and try to use a model string from a similar device to experimentally use your device with the binding. - -| Parameter | Type | Required | Description | -|-----------------|---------|----------|-------------------------------------------------------------------| -| host | text | true | Device IP address | -| token | text | true | Token for communication (in Hex) | -| deviceId | text | true | Device ID number for communication (in Hex) | -| model | text | false | Device model string, used to determine the subtype | -| refreshInterval | integer | false | Refresh interval for refreshing the data in seconds. (0=disabled) | -| timeout | integer | false | Timeout time in milliseconds | - - -### Example Thing file - -`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="0326xxxx" ]` - ## Channels Depending on the device, different channels are available. @@ -307,7 +323,7 @@ e.g. `smarthome:send actionCommand 'upd_timer["1498595904821", "on"]'` would ena | Channel | Type | Description | |------------------|---------|-------------------------------------| | power | Switch | Power | -| mode | String | Mode | +| humidifierMode | String | Mode | | humidity | Number | Humidity | | setHumidity | Number | Humidity Set | | bright | Number | LED Brightness | @@ -1380,6 +1396,7 @@ Group gVacStat "Status Details" (gVac) Group gVacCons "Consumables Usage" (gVac) Group gVacDND "Do Not Disturb Settings" (gVac) Group gVacHist "Cleaning History" (gVac) +Group gVacLast "Last Cleaning Details" (gVac) String actionControl "Vacuum Control" {channel="miio:vacuum:034F0E45:actions#control" } String actionCommand "Vacuum Command" {channel="miio:vacuum:034F0E45:actions#commands" } @@ -1405,8 +1422,18 @@ String dndEnd "DND End Time [%s]" (gVacDND) {channel="miio:vacuu Number historyArea "Total Cleaned Area [%1.0fm²]" (gVacHist) {channel="miio:vacuum:034F0E45:history#total_clean_area"} String historyTime "Total Clean Time [%s]" (gVacHist) {channel="miio:vacuum:034F0E45:history#total_clean_time"} Number historyCount "Total # Cleanings [%1.0f]" (gVacHist) {channel="miio:vacuum:034F0E45:history#total_clean_count"} -``` +String lastStart "Last Cleaning Start time [%s]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_start_time"} +String lastEnd "Last Cleaning End time [%s]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_end_time"} +Number lastArea "Last Cleaned Area [%1.0fm²]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_area"} +Number lastTime "Last Clean Time [%1.0f']" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_duration"} +Number lastError "Error [%s]" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_error" } +Switch lastCompleted "Last Cleaning Completed" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#last_clean_finish" } + + +Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"} +``` +Note: cleaning map is only available with cloud access. ### Mi Air Monitor v1 (zhimi.airmonitor.v1) item file lines @@ -1501,9 +1528,9 @@ Switch childlock "Child Lock" (G_humidifier) {channel="miio:basic:humidifier:chi note: Autogenerated example. Replace the id (humidifier) in the channel with your own. Replace `basic` with `generic` in the thing UID depending on how your thing was discovered. ```java -Group G_humidifier "Mi Air Humidifier" +Group G_humidifier "Mi Air Humidifier 2" Switch power "Power" (G_humidifier) {channel="miio:basic:humidifier:power"} -String mode "Mode" (G_humidifier) {channel="miio:basic:humidifier:mode"} +String humidifierMode "Mode" (G_humidifier) {channel="miio:basic:humidifier:humidifierMode"} Number humidity "Humidity" (G_humidifier) {channel="miio:basic:humidifier:humidity"} Number setHumidity "Humidity Set" (G_humidifier) {channel="miio:basic:humidifier:setHumidity"} Number bright "LED Brightness" (G_humidifier) {channel="miio:basic:humidifier:bright"} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConfiguration.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConfiguration.java index 18427d8b90c88..ca71dd4cf8135 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConfiguration.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConfiguration.java @@ -26,4 +26,5 @@ public final class MiIoBindingConfiguration { public String model; public int refreshInterval; public int timeout; + public String cloudServer; } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConstants.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConstants.java index 5c149f8e9e129..66f79d6615fcc 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConstants.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoBindingConstants.java @@ -94,6 +94,7 @@ public final class MiIoBindingConstants { public static final String CHANNEL_HISTORY_ERROR = "cleaning#last_clean_error"; public static final String CHANNEL_HISTORY_FINISH = "cleaning#last_clean_finish"; public static final String CHANNEL_HISTORY_RECORD = "cleaning#last_clean_record"; + public static final String CHANNEL_VACUUM_MAP = "cleaning#map"; public static final String PROPERTY_HOST_IP = "host"; public static final String PROPERTY_DID = "deviceId"; @@ -101,6 +102,7 @@ public final class MiIoBindingConstants { public static final String PROPERTY_MODEL = "model"; public static final String PROPERTY_REFRESH_INTERVAL = "refreshInterval"; public static final String PROPERTY_TIMEOUT = "timeout"; + public static final String PROPERTY_CLOUDSERVER = "cloudServer"; public static final byte[] DISCOVER_STRING = org.openhab.binding.miio.internal.Utils .hexStringToByteArray("21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoDevices.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoDevices.java index 59d2799511e66..b2272cbd2e8bf 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoDevices.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoDevices.java @@ -104,7 +104,20 @@ public enum MiIoDevices { VACUUM_C1("roborock.vacuum.c1", "Mi Xiaowa Vacuum c1", THING_TYPE_VACUUM), VACUUM2("roborock.vacuum.s5", "Mi Robot Vacuum v2", THING_TYPE_VACUUM), VACUUM1S("roborock.vacuum.m1s", "Mi Robot Vacuum 1S", THING_TYPE_VACUUM), - VACUUMS6("roborock.vacuum.s6", "Roborock Vacuum S6", THING_TYPE_VACUUM), + VACUUMS4("roborock.vacuum.s4", "Mi Robot Vacuum S4", THING_TYPE_VACUUM), + VACUUMSTS4V2("roborock.vacuum.s4v2", "Roborock Vacuum S4v2", THING_TYPE_VACUUM), + VACUUMST6("roborock.vacuum.t6", "Roborock Vacuum T6", THING_TYPE_VACUUM), + VACUUMST6V2("roborock.vacuum.t6v2", "Roborock Vacuum T6 v2", THING_TYPE_VACUUM), + VACUUMST6V3("roborock.vacuum.t6v3", "Roborock Vacuum T6 v3", THING_TYPE_VACUUM), + VACUUMST4("roborock.vacuum.t4", "Roborock Vacuum T4", THING_TYPE_VACUUM), + VACUUMST4V2("roborock.vacuum.t4v2", "Roborock Vacuum T4 v2", THING_TYPE_VACUUM), + VACUUMST4V3("roborock.vacuum.t4v3", "Roborock Vacuum T4 v3", THING_TYPE_VACUUM), + VACUUMST7("roborock.vacuum.t7", "Roborock Vacuum T7", THING_TYPE_VACUUM), + VACUUMST7V2("roborock.vacuum.t7v2", "Roborock Vacuum T7 v2", THING_TYPE_VACUUM), + VACUUMST7V3("roborock.vacuum.t7v3", "Roborock Vacuum T7 v3", THING_TYPE_VACUUM), + VACUUMST7P("roborock.vacuum.t7p", "Roborock Vacuum T7p", THING_TYPE_VACUUM), + VACUUMST7PV2("roborock.vacuum.t7pv2", "Roborock Vacuum T7 v2", THING_TYPE_VACUUM), + VACUUMST7PV3("roborock.vacuum.t7pv3", "Roborock Vacuum T7 v3", THING_TYPE_VACUUM), VACUUMS5MAX("roborock.vacuum.s5e", "Roborock Vacuum S5 Max", THING_TYPE_VACUUM), VACUUMSS6("rockrobo.vacuum.s6", "Roborock Vacuum S6", THING_TYPE_VACUUM), VACUUME2("roborock.vacuum.e2", "Rockrobo Xiaowa Vacuum v2", THING_TYPE_UNSUPPORTED), diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java index 3970a31b97b7b..e2ad3c5bb5202 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java @@ -16,16 +16,21 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import java.util.Dictionary; +import org.eclipse.jetty.client.HttpClient; import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingTypeUID; import org.eclipse.smarthome.core.thing.binding.BaseThingHandlerFactory; import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.eclipse.smarthome.core.thing.binding.ThingHandlerFactory; +import org.eclipse.smarthome.io.net.http.HttpClientFactory; import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService; +import org.openhab.binding.miio.internal.cloud.CloudConnector; import org.openhab.binding.miio.internal.handler.MiIoBasicHandler; import org.openhab.binding.miio.internal.handler.MiIoGenericHandler; import org.openhab.binding.miio.internal.handler.MiIoUnsupportedHandler; import org.openhab.binding.miio.internal.handler.MiIoVacuumHandler; +import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -41,10 +46,27 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory { private MiIoDatabaseWatchService miIoDatabaseWatchService; + private @NonNullByDefault({}) HttpClient httpClient; @Activate - public MiIoHandlerFactory(@Reference MiIoDatabaseWatchService miIoDatabaseWatchService) { + public MiIoHandlerFactory(@Reference MiIoDatabaseWatchService miIoDatabaseWatchService, + @Reference HttpClientFactory httpClientFactory) { this.miIoDatabaseWatchService = miIoDatabaseWatchService; + this.httpClient = httpClientFactory.createHttpClient(BINDING_ID); + } + + @Override + protected void activate(ComponentContext componentContext) { + super.activate(componentContext); + Dictionary properties = componentContext.getProperties(); + @Nullable + String username = (String) properties.get("username"); + @Nullable + String password = (String) properties.get("password"); + @Nullable + String country = (String) properties.get("country"); + CloudConnector.getInstance().setHttpClient(httpClient); + CloudConnector.getInstance().setCredentials(username, password, country); } @Override diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java new file mode 100644 index 0000000000000..4dc82b2be55be --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java @@ -0,0 +1,240 @@ +/** + * Copyright (c) 2010-2020 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.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.smarthome.core.cache.ExpiringCache; +import org.eclipse.smarthome.io.net.http.HttpUtil; +import org.jetbrains.annotations.NotNull; +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; + +/** + * The {@link CloudConnector} is responsible for connecting OH to the Xiaomi cloud communication. + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class CloudConnector { + + protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(60); + private static final int FAILED = -1; + private static final int STARTING = 0; + private static final int REFRESHING = 1; + private static final int AVAILABLE = 2; + private int deviceListState = STARTING; + + private String username = ""; + private String password = ""; + private String country = "ru,us,tw,sg,cn,de"; + + private List deviceList = new ArrayList(); + private final JsonParser parser = new JsonParser(); + private boolean connected; + + private @Nullable HttpClient httpClient; + + private @Nullable MiCloudConnector cloudConnector; + public static final CloudConnector CC_INSTANCE = new CloudConnector(); + private final Logger logger = LoggerFactory.getLogger(CloudConnector.class); + + private ExpiringCache logonCache = new ExpiringCache(CACHE_EXPIRY, () -> { + return logon(); + }); + + private ExpiringCache refreshDeviceList = new ExpiringCache(CACHE_EXPIRY, () -> { + if (deviceListState == FAILED && !isConnected()) { + return ("Could not connect to Xiaomi cloud"); + } + final @Nullable MiCloudConnector cl = this.cloudConnector; + if (cl == null) { + return ("Could not connect to Xiaomi cloud"); + } + deviceListState = REFRESHING; + deviceList.clear(); + for (String server : country.split(",")) { + try { + JsonElement response = parser.parse(cl.getDevices(server)); + if (response.isJsonObject() && response.getAsJsonObject().has("result") + && response.getAsJsonObject().get("result").isJsonObject()) { + JsonObject result = response.getAsJsonObject().get("result").getAsJsonObject(); + result.addProperty("server", server); + deviceList.add(result); + } + } catch (JsonParseException e) { + logger.debug("Parsing error getting devices: {}", e.getMessage()); + } + } + deviceListState = AVAILABLE; + return "done";// deviceList; + }); + + private CloudConnector() { + } + + public static CloudConnector getInstance() { + return CC_INSTANCE; + } + + public boolean isConnected() { + final MiCloudConnector cl = cloudConnector; + if (cl != null && cl.hasLoginToken()) { + return true; + } + final @Nullable Boolean c = logonCache.getValue(); + if (c != null && c.booleanValue()) { + return true; + } + deviceListState = FAILED; + return false; + } + + public byte[] getMap(String mapId, String country) throws MiCloudException { + logger.info("Getting vacuum map {} from Xiaomi cloud server: {}", mapId, country); + String mapCountry; + String mapUrl = ""; + final @Nullable MiCloudConnector cl = this.cloudConnector; + if (cl == null || !isConnected()) { + throw new MiCloudException("Cannot execute request. Cloudservice not available"); + } + if (country.isEmpty()) { + // TODO: pick the right server in a more intelligent way + logger.debug("Server not defined in thing. Trying servers: {}", this.country); + for (String mapCountryServer : this.country.split(",")) { + ; + mapCountry = mapCountryServer.trim().toLowerCase(); + mapUrl = cl.getMapUrl(mapId, mapCountry); + logger.debug("Map download from server {} returned {}", mapCountry, mapUrl); + + if (!mapUrl.isEmpty()) { + break; + } + } + } else { + mapCountry = country.trim().toLowerCase(); + mapUrl = cl.getMapUrl(mapId, mapCountry); + } + byte[] mapData = HttpUtil.downloadData(mapUrl, null, false, -1).getBytes(); + return mapData; + } + + public void setCredentials(@Nullable String username, @Nullable String password, @Nullable String country) { + if (country != null) { + this.country = country; + } + if (username != null && password != null) { + this.username = username; + this.password = password; + logon(); + } + } + + public void setHttpClient(@NotNull HttpClient httpClient) { + this.httpClient = httpClient; + } + + private boolean logon() { + if (username.isEmpty() || password.isEmpty()) { + logger.info("No Xiaomi cloud credentials. Cloud connectivity diabled"); + logger.debug("Username: {} pass: {}, country:{}", username, password.replaceAll(".", "*"), country); + return connected; + } + final HttpClient httpClient = this.httpClient; + if (httpClient != null) { + try { + final MiCloudConnector cl = new MiCloudConnector(username, password, httpClient); + this.cloudConnector = cl; + connected = cl.login(); + if (connected) { + getDevicesList(); + } else { + deviceListState = FAILED; + } + } catch (MiCloudException e) { + connected = false; + deviceListState = FAILED; + logger.debug("Xiaomi cloud login failed: {}", e.getMessage()); + } + } else { + logger.info("HTTP client not set. Cloud connectivity diabled"); + connected = false; + deviceListState = FAILED; + } + return connected; + } + + public List getDevicesList() { + refreshDeviceList.getValue(); + return deviceList; + + } + + public JsonObject getDeviceInfo(String id) { + getDevicesList(); + if (deviceListState < AVAILABLE) { + JsonObject returnvalue = new JsonObject(); + returnvalue.addProperty("deviceListState", deviceListState); + return returnvalue; + } + String did = Long.toString(Long.parseUnsignedLong(id, 16)); + List devicedata = new ArrayList(); + for (JsonObject countyDeviceList : deviceList) { + if (countyDeviceList.has("list") && countyDeviceList.get("list").isJsonArray()) { + for (JsonElement device : countyDeviceList.get("list").getAsJsonArray()) { + if (device.isJsonObject() && device.getAsJsonObject().has("did") + && device.getAsJsonObject().get("did").getAsString().contentEquals(did) + && device.getAsJsonObject().has("token")) { + JsonObject deviceDetails = device.getAsJsonObject(); + deviceDetails.addProperty("server", countyDeviceList.get("server").getAsString()); + devicedata.add(deviceDetails); + } + } + } + } + + JsonObject returnvalue = new JsonObject(); + switch (devicedata.size()) { + case 0: + returnvalue.addProperty("connected", connected); + break; + case 1: + returnvalue = devicedata.get(0); + break; + default: + for (JsonObject device : devicedata) { + if (device.has("isOnline") && device.get("isOnline").getAsBoolean()) { + return device; + } + } + logger.debug("Found multiple servers for device, with device offline {} {} ", + devicedata.get(0).get("name").getAsString(), id); + for (JsonObject device : devicedata) { + logger.debug("Server {} token: {}", device.get("server").getAsString(), + device.get("token").getAsString()); + } + returnvalue = devicedata.get(0); + } + return returnvalue; + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java new file mode 100644 index 0000000000000..db1e3563aa0b4 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2010-2020 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.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.miio.internal.MiIoCryptoException; + +/** + * The {@link CloudCrypto} is responsible for encryption for Xiaomi cloud communication. + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class CloudCrypto { + + /** + * Compute SHA256 hash value for the byte array + * + * @param inBytes ByteArray to be hashed + * @return BASE64 encoded hash value + * @throws MiIoCryptoException + */ + public static String sha256Hash(byte[] inBytes) throws MiIoCryptoException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return Base64.getEncoder().encodeToString(md.digest(inBytes)); + } catch (NoSuchAlgorithmException e) { + throw new MiIoCryptoException(e.getMessage()); + } + } + + /** + * Compute HmacSHA256 hash value for the byte array + * + * @param key for encoding + * @param cipherText ByteArray to be encoded + * @return BASE64 encoded hash value + * @throws MiIoCryptoException + */ + public static String hMacSha256Encode(byte[] key, byte[] cipherText) throws MiIoCryptoException { + try { + Mac sha256Hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKey = new SecretKeySpec(key, "HmacSHA256"); + sha256Hmac.init(secretKey); + return Base64.getEncoder().encodeToString(sha256Hmac.doFinal(cipherText)); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new MiIoCryptoException(e.getMessage()); + } + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java new file mode 100644 index 0000000000000..ee06e1be235a6 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java @@ -0,0 +1,172 @@ +/** + * Copyright (c) 2010-2020 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.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.TreeMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.config.core.ConfigConstants; +import org.openhab.binding.miio.internal.MiIoBindingConstants; +import org.openhab.binding.miio.internal.MiIoCryptoException; +import org.slf4j.Logger; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link CloudUtil} class is used for supporting functions for Xiaomi cloud access + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class CloudUtil { + + private static final Random RANDOM = new Random(); + + public static String getElementString(JsonElement jsonElement, String element, Logger logger) { + String value = ""; + try { + value = jsonElement.getAsJsonObject().get(element).getAsString(); + } catch (Exception e) { + logger.debug("Json Element {} expected but missing", element); + } + return value; + } + + public static void printDevices(String response, String country, Logger logger) { + try { + final JsonElement resp = new JsonParser().parse(response); + if (resp.isJsonObject()) { + final JsonObject jor = resp.getAsJsonObject(); + String result = jor.get("result").getAsJsonObject().toString(); + for (JsonElement di : jor.get("result").getAsJsonObject().get("list").getAsJsonArray()) { + if (di.isJsonObject()) { + final JsonObject deviceInfo = di.getAsJsonObject(); + logger.debug( + "Xiaomi cloud info: device name: '{}', did: '{}', token: '{}', ip: {}, server: {} ", + deviceInfo.get("name").getAsString(), deviceInfo.get("did").getAsString(), + deviceInfo.get("token").getAsString(), deviceInfo.get("localip").getAsString(), + country); + } + } + logger.trace("Devices: {}", result); + } else { + logger.debug("Response is not a json object: '{}'", response); + } + } catch (JsonSyntaxException | IllegalStateException e) { + logger.info("Error while printing devices: {}", e.getMessage()); + } + + } + + public static void saveFile(String data, String country, Logger logger) { + String dbFolderName = ConfigConstants.getUserDataFolder() + File.separator + MiIoBindingConstants.BINDING_ID; + File folder = new File(dbFolderName); + if (!folder.exists()) { + folder.mkdirs(); + } + File dataFile = new File(dbFolderName + File.separator + "miioTokens-" + country + ".json"); + try (FileWriter writer = new FileWriter(dataFile)) { + writer.write(data); + logger.debug("Devices token info saved to {}", dataFile.getAbsolutePath()); + } catch (IOException e) { + logger.debug("Failed to write token file '{}': {}", dataFile.getName(), e.getMessage()); + } + } + + /** + * Generate signature for the request. + * + * @param method http request method. GET or POST + * @param requestUrl the full request url. e.g.: http://api.xiaomi.com/getUser?id=123321 + * @param params request params. This should be a TreeMap because the + * parameters are required to be in lexicographic order. + * @param signedNonce secret key for encryption. + * @return hash value for the values provided + * @throws MiIoCryptoException + */ + public static String generateSignature(@Nullable String requestUrl, @Nullable String signedNonce, String nonce, + @Nullable Map params) throws MiIoCryptoException { + if (signedNonce == null || signedNonce.length() == 0) { + throw new MiIoCryptoException("key is not nullable"); + } + List exps = new ArrayList(); + + if (requestUrl != null) { + URI uri = URI.create(requestUrl); + exps.add(uri.getPath()); + } + exps.add(signedNonce); + exps.add(nonce); + + if (params != null && !params.isEmpty()) { + final TreeMap sortedParams = new TreeMap(params); + Set> entries = sortedParams.entrySet(); + for (Map.Entry entry : entries) { + exps.add(String.format("%s=%s", entry.getKey(), entry.getValue())); + } + } + boolean first = true; + StringBuilder sb = new StringBuilder(); + for (String s : exps) { + if (!first) { + sb.append('&'); + } else { + first = false; + } + sb.append(s); + } + return CloudCrypto.hMacSha256Encode(Base64.getDecoder().decode(signedNonce), sb.toString().getBytes()); + } + + public static String generateNonce(long milli) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + DataOutputStream dataOutputStream = new DataOutputStream(output); + dataOutputStream.writeLong(RANDOM.nextLong()); + dataOutputStream.writeInt((int) (milli / 60000)); + dataOutputStream.flush(); + return Base64.getEncoder().encodeToString(output.toByteArray()); + } + + public static String signedNonce(String ssecret, String nonce) throws IOException, MiIoCryptoException { + byte[] byteArrayS = Base64.getDecoder().decode(ssecret.getBytes()); + byte[] byteArrayN = Base64.getDecoder().decode(nonce.getBytes()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + output.write(byteArrayS); + output.write(byteArrayN); + return CloudCrypto.sha256Hash(output.toByteArray()); + } + + public static void writeBytesToFileNio(byte[] bFile, String fileDest) throws IOException { + Path path = Paths.get(fileDest); + Files.write(path, bFile); + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java new file mode 100644 index 0000000000000..54d334f2be97f --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -0,0 +1,468 @@ +/** + * Copyright (c) 2010-2020 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.io.IOException; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpResponseException; +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.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +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.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link MiCloudConnector} class is used for connecting to the Xiaomi cloud access + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class MiCloudConnector { + + private static final int REQUEST_TIMEOUT_SECONDS = 10; + private static final String UNEXPECTED = "Unexpected :"; + private static final String AGENT_ID = (new Random().ints(65, 70).limit(13) + .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 + + " APP/xiaomi.smarthome APPV/62830"; + private final JsonParser parser = new JsonParser(); + + // String + private String username; + private String password; + + private final String clientId; + private String userId = ""; + private String serviceToken = ""; + private String ssecurity = ""; + int loginFailedCounter = 0; + + private HttpClient httpClient; + + private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class); + + public MiCloudConnector(String username, String password, HttpClient httpClient) throws MiCloudException { + this.username = username; + this.password = password; + this.httpClient = httpClient; + if (!checkCredentials()) { + 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()); + } + + void startClient() throws MiCloudException { + if (!httpClient.isStarted()) { + try { + httpClient.start(); + // set default cookies + CookieStore cookieStore = httpClient.getCookieStore(); + addCookie(cookieStore, "sdkVersion", "accountsdk-18.8.15", "mi.com"); + addCookie(cookieStore, "sdkVersion", "accountsdk-18.8.15", "xiaomi.com"); + addCookie(cookieStore, "deviceId", this.clientId, "mi.com"); + addCookie(cookieStore, "deviceId", this.clientId, "xiaomi.com"); + } catch (Exception e) { + throw new MiCloudException("No http client cannot be started: " + e.getMessage()); + } + } + } + + public void stopClient() { + try { + this.httpClient.stop(); + } catch (Exception e) { + logger.debug("Error stopping httpclient :{}", e.getMessage()); + } + } + + private boolean checkCredentials() { + if (username.trim().isEmpty() || password.trim().isEmpty()) { + logger.info("Xiaomi Cloud: username or password missing."); + return false; + } + return true; + } + + private String getApiUrl(String country) { + return "https://" + (country.trim().equalsIgnoreCase("cn") ? "" : country.trim().toLowerCase() + ".") + + "api.io.mi.com/app"; + } + + public String getClientId() { + return clientId; + } + + String parseJson(String data) { + 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"; + Map map = new HashMap(); + map.put("data", "{\"obj_name\":\"" + vacuumMap + "\"}"); + String mapResponse = request(url, map); + logger.trace("response: {}", mapResponse); + String errorMsg = ""; + JsonElement response = parser.parse(mapResponse); + if (response.isJsonObject()) { + logger.debug("Received JSON message {}", response.toString()); + if (response.getAsJsonObject().has("result") && response.getAsJsonObject().get("result").isJsonObject()) { + JsonObject jo = response.getAsJsonObject().get("result").getAsJsonObject(); + if (jo.has("url")) { + return jo.get("url").getAsString(); + } else { + errorMsg = "Could not get url"; + } + } else { + errorMsg = "Could not get result"; + } + } else { + errorMsg = "Received message is invalid JSON"; + } + logger.debug("{}: {}", errorMsg, mapResponse); + return ""; + } + + public String getDeviceStatus(String device, String country) throws MiCloudException { + String url = getApiUrl(country) + "/home/device_list"; + Map map = new HashMap(); + map.put("data", "{\"dids\":[\"" + device + "\"]}"); + logger.debug("response: {}", request(url, map)); + return ""; + } + + public String getLatest(String model, String country) { + String url = getApiUrl(country) + "/home/latest_version"; + Map map = new HashMap(); + map.put("data", "{\"model\":\"" + model + "\"}"); + String resp; + try { + resp = request(url, map); + logger.debug("Response: {}", resp); + // CloudUtil.printDevices(resp, logger); + if (resp.length() > 2) { + // CloudUtil.saveFile(resp, country, logger); + return resp; + } + } catch (MiCloudException e) { + logger.debug("{}", e.getMessage()); + return ""; + } + return ""; + } + + public String getDevices(String country) { + String url = getApiUrl(country) + "/home/device_list"; + Map map = new HashMap(); + map.put("data", "{\"getVirtualModel\":false,\"getHuamiDevices\":0}"); + String resp; + try { + resp = request(url, map); + logger.trace("Get devices response: {}", resp); + CloudUtil.printDevices(resp, country, logger); + if (resp.length() > 2) { + CloudUtil.saveFile(resp, country, logger); + return resp; + } + } catch (MiCloudException e) { + logger.info("{}", e.getMessage()); + } + return ""; + } + + public String request(String urlPart, String country, Map params) throws MiCloudException { + String url = getApiUrl(country) + urlPart; + String response = request(url, params); + logger.debug("Request to {} server {}. Response: {}", country, urlPart, response); + return response; + } + + public String request(String url, Map params) throws MiCloudException { + if (this.serviceToken.isEmpty() || this.userId.isEmpty()) { + throw new MiCloudException("Cannot execute request. service token or userId missing"); + } + try { + startClient(); + } catch (Exception e) { + throw new MiCloudException("Cannot Execute request. service token or userId missing" + e.getMessage()); + } + logger.debug("Send request: {} to {}", params.get("data"), url); + Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + request.agent(USERAGENT); + request.header("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2"); + request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded"); + request.cookie(new HttpCookie("userId", this.userId)); + request.cookie(new HttpCookie("yetAnotherServiceToken", this.serviceToken)); + request.cookie(new HttpCookie("serviceToken", this.serviceToken)); + request.cookie(new HttpCookie("locale", "uk_GB")); + request.cookie(new HttpCookie("timezone", "GMT%2B01%3A00")); + request.cookie(new HttpCookie("is_daylight", "1")); + request.cookie(new HttpCookie("dst_offset", "3600000")); + request.cookie(new HttpCookie("channel", "MI_APP_STORE")); + + for (HttpCookie cookie : request.getCookies()) { + logger.trace("Cookie set for request ({}) : {} --> {} (path: {})", cookie.getDomain(), cookie.getName(), + cookie.getValue(), cookie.getPath()); + } + String method = "POST"; + request.method(method); + + try { + String nonce = CloudUtil.generateNonce(System.currentTimeMillis()); + String signedNonce = CloudUtil.signedNonce(ssecurity, nonce); + String signature = CloudUtil.generateSignature(url.replace("/app", ""), signedNonce, nonce, params); + + Fields fields = new Fields(); + fields.put("signature", signature); + fields.put("_nonce", nonce); + fields.put("data", params.get("data")); + request.content(new FormContentProvider(fields)); + + logger.trace("fieldcontent: {}", fields.toString()); + final ContentResponse response = request.send(); + return response.getContentAsString(); + } catch (HttpResponseException e) { + serviceToken = ""; + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (InterruptedException e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (TimeoutException e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (ExecutionException e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (IOException e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (MiIoCryptoException e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage(), e); + } catch (Exception e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage(), e); + } + return ""; + } + + private void addCookie(CookieStore cookieStore, String name, String value, String domain) { + HttpCookie cookie = new HttpCookie(name, value); + cookie.setDomain("." + domain); + cookie.setPath("/"); + cookieStore.add(URI.create("https://" + domain), cookie); + } + + // TODO: better way instead of blocking ? + public synchronized boolean login() { + if (!checkCredentials()) { + return false; + } + if (!userId.isEmpty() && !serviceToken.isEmpty()) { + return true; + } + 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 = ""; + return false; + } + return true; + } + + protected boolean loginRequest() throws MiCloudException { + try { + startClient(); + String sign = loginStep1(); + String location = loginStep2(sign); + 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?"); + 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 | TimeoutException | ExecutionException e) { + throw new MiCloudException("Cannot logon to Xiaomi cloud: " + e.getMessage()); + } catch (MiIoCryptoException e) { + throw new MiCloudException("Error decrypting. Cannot logon to Xiaomi cloud: " + e.getMessage()); + } catch (MalformedURLException e) { + throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage()); + } + + } + + private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException { + 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 = new JsonParser().parse(parseJson(content)); + String sign = resp.getAsJsonObject().get("_sign").getAsString(); + logger.trace("Xiaomi Login step 1 sign = {}", sign); + return sign; + } catch (JsonSyntaxException e) { + throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage()); + } + } + + private String loginStep2(String sign) + throws MiIoCryptoException, InterruptedException, TimeoutException, ExecutionException, MiCloudException { + 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", encodePassword(password)); + 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); + 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 = new JsonParser().parse(parseJson(content2)); + ssecurity = CloudUtil.getElementString(resp2, "ssecurity", logger); + userId = CloudUtil.getElementString(resp2, "userId", logger); + cUserId = CloudUtil.getElementString(resp2, "cUserId", logger); + passToken = CloudUtil.getElementString(resp2, "passToken", logger); + String location = CloudUtil.getElementString(resp2, "location", logger); + String code = CloudUtil.getElementString(resp2, "code", logger); + + 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); + + dumpCookies(url); + 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 { + final ContentResponse responseStep3; + Request request; + logger.trace("Xiaomi Login step 3 @ {}", (new URL(location)).getHost()); + request = httpClient.newRequest(location).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + request.agent(USERAGENT); + request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded"); + responseStep3 = request.send(); + logger.trace("Xiaomi login step 3 content = {}", responseStep3.getContentAsString()); + logger.trace("Xiaomi login step 3 response = {}", responseStep3); + + dumpCookies(location); + URI uri = URI.create("http://sts.api.io.mi.com"); + String serviceToken = extractServiceToken(uri); + if (!serviceToken.isEmpty()) { + this.serviceToken = serviceToken; + } + return responseStep3; + } + + private void dumpCookies(String url) { + URI uri = URI.create(url); + logger.trace("Cookie dump for {}", url); + List cookies = httpClient.getCookieStore().get(uri); + for (HttpCookie cookie : cookies) { + logger.trace("Cookie ({}) : {} --> {} (path: {})", cookie.getDomain(), cookie.getName(), + cookie.getValue(), cookie.getPath()); + } + } + + private String extractServiceToken(URI uri) { + String serviceToken = ""; + List cookies = httpClient.getCookieStore().get(uri); + for (HttpCookie cookie : cookies) { + logger.trace("Cookie :{} --> {}", cookie.getName(), cookie.getValue()); + if (cookie.getName().contentEquals("serviceToken")) { + serviceToken = cookie.getValue(); + logger.debug("Xiaomi cloud logon succesfull."); + logger.trace("Xiaomi cloud servicetoken: {}", serviceToken); + + } + } + return serviceToken; + } + + public boolean hasLoginToken() { + return !serviceToken.isEmpty(); + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java new file mode 100644 index 0000000000000..839aaa8e9c1ad --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2020 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; + +/** + * Will be thrown for cloud errors + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class MiCloudException extends Exception { + + public MiCloudException() { + super(); + } + + public MiCloudException(String arg0) { + super(arg0); + } + + /** + * required variable to avoid IncorrectMultilineIndexException warning + */ + private static final long serialVersionUID = -1280858607995252321L; +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java index bd846e43753f3..a6798c255f2bb 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java @@ -35,10 +35,13 @@ import org.eclipse.smarthome.core.thing.ThingUID; import org.openhab.binding.miio.internal.Message; import org.openhab.binding.miio.internal.Utils; +import org.openhab.binding.miio.internal.cloud.CloudConnector; import org.osgi.service.component.annotations.Component; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.JsonObject; + /** * The {@link MiIoDiscovery} is responsible for discovering new Xiaomi Mi IO devices * and their token @@ -132,6 +135,19 @@ private void discovered(String ip, byte[] response) { String token = Utils.getHex(msg.getChecksum()); String id = Utils.getHex(msg.getDeviceId()); String label = "Xiaomi Mi Device " + id + " (" + Long.parseUnsignedLong(id, 16) + ")"; + String country = ""; + boolean isOnline = false; + if (CloudConnector.getInstance().isConnected()) { + CloudConnector.getInstance().getDevicesList(); + JsonObject cloudInfo = CloudConnector.getInstance().getDeviceInfo(id); + logger.debug("Cloud Response: {}", cloudInfo.toString()); + if (cloudInfo.has("token")) { + token = cloudInfo.get("token").getAsString(); + label = cloudInfo.get("name").getAsString() + " " + id + " (" + Long.parseUnsignedLong(id, 16) + ")"; + country = cloudInfo.get("server").getAsString(); + isOnline = cloudInfo.get("isOnline").getAsBoolean(); + } + } ThingUID uid = new ThingUID(THING_TYPE_MIIO, id); logger.debug("Discovered Mi Device {} ({}) at {} as {}", id, Long.parseUnsignedLong(id, 16), ip, uid); DiscoveryResultBuilder dr = DiscoveryResultBuilder.create(uid).withProperty(PROPERTY_HOST_IP, ip) @@ -145,6 +161,9 @@ private void discovered(String ip, byte[] response) { logger.debug("Discovered token for device {}: {}", id, token); dr = dr.withProperty(PROPERTY_TOKEN, token).withRepresentationProperty(id).withLabel(label + " with token"); } + if (!country.isEmpty() && isOnline) { + dr = dr.withProperty(PROPERTY_CLOUDSERVER, country); + } thingDiscovered(dr.build()); } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java index a8645f8a00b5b..cb81486f7cba4 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java @@ -14,16 +14,26 @@ import static org.openhab.binding.miio.internal.MiIoBindingConstants.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.Date; import java.util.concurrent.TimeUnit; +import javax.imageio.ImageIO; + import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.config.core.ConfigConstants; import org.eclipse.smarthome.core.cache.ExpiringCache; import org.eclipse.smarthome.core.library.types.DateTimeType; import org.eclipse.smarthome.core.library.types.DecimalType; import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.RawType; import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.thing.ChannelUID; import org.eclipse.smarthome.core.thing.Thing; @@ -32,11 +42,16 @@ import org.eclipse.smarthome.core.types.RefreshType; import org.eclipse.smarthome.core.types.State; import org.eclipse.smarthome.core.types.UnDefType; +import org.openhab.binding.miio.internal.MiIoBindingConfiguration; import org.openhab.binding.miio.internal.MiIoCommand; import org.openhab.binding.miio.internal.MiIoSendCommand; import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService; +import org.openhab.binding.miio.internal.cloud.CloudConnector; +import org.openhab.binding.miio.internal.cloud.CloudUtil; +import org.openhab.binding.miio.internal.cloud.MiCloudException; import org.openhab.binding.miio.internal.robot.ConsumablesType; import org.openhab.binding.miio.internal.robot.FanModeType; +import org.openhab.binding.miio.internal.robot.RRMapDraw; import org.openhab.binding.miio.internal.robot.StatusType; import org.openhab.binding.miio.internal.robot.VacuumErrorType; import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication; @@ -55,15 +70,21 @@ @NonNullByDefault public class MiIoVacuumHandler extends MiIoAbstractHandler { private final Logger logger = LoggerFactory.getLogger(MiIoVacuumHandler.class); + private static final float MAP_SCALE = 2.0f; + private final ChannelUID mapChannelUid; + private ExpiringCache status; private ExpiringCache consumables; private ExpiringCache dnd; private ExpiringCache history; - private String lastHistoryId = ""; private int inCleaning; + private ExpiringCache map; + private String lastHistoryId = ""; + private String lastMap = ""; public MiIoVacuumHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService) { super(thing, miIoDatabaseWatchService); + mapChannelUid = new ChannelUID(thing.getUID(), CHANNEL_VACUUM_MAP); initializeData(); status = new ExpiringCache(CACHE_EXPIRY, () -> { try { @@ -109,6 +130,17 @@ public MiIoVacuumHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatch } return null; }); + map = new ExpiringCache(CACHE_EXPIRY, () -> { + try { + int ret = sendCommand(MiIoCommand.GET_MAP); + if (ret != 0) { + return "id:" + ret; + } + } catch (Exception e) { + logger.debug("Error during dnd refresh: {}", e.getMessage(), e); + } + return null; + }); } @Override @@ -120,6 +152,10 @@ public void handleCommand(ChannelUID channelUID, Command command) { if (command == RefreshType.REFRESH) { logger.debug("Refreshing {}", channelUID); updateData(); + lastMap = ""; + if (channelUID.getId().equals(CHANNEL_VACUUM_MAP)) { + sendCommand(MiIoCommand.GET_MAP); + } return; } if (channelUID.getId().equals(CHANNEL_VACUUM)) { @@ -348,6 +384,11 @@ protected synchronized void updateData() { status.getValue(); refreshNetwork(); consumables.getValue(); + if (lastMap.isEmpty() || inCleaning != 0) { + if (isLinked(mapChannelUid)) { + map.getValue(); + } + } } catch (Exception e) { logger.debug("Error while updating '{}': '{}", getThing().getUID().toString(), e.getLocalizedMessage()); } @@ -395,6 +436,16 @@ public void onMessageReceived(MiIoSendCommand response) { logger.debug("Could not extract cleaning history record from: {}", response); } break; + case GET_MAP: + if (response.getResult().isJsonArray()) { + String mapresponse = response.getResult().getAsJsonArray().get(0).getAsString(); + if (!mapresponse.contentEquals("retry") && !mapresponse.contentEquals(lastMap)) { + lastMap = mapresponse; + scheduler.schedule(() -> updateState(CHANNEL_VACUUM_MAP, getMap(mapresponse)), 0, + TimeUnit.MILLISECONDS); + } + } + break; case UNKNOWN: updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString())); break; @@ -402,4 +453,38 @@ public void onMessageReceived(MiIoSendCommand response) { break; } } + + private State getMap(String map) { + final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); + if (CloudConnector.getInstance().isConnected()) { + try { + byte[] mapData = CloudConnector.getInstance().getMap(map, + (configuration.cloudServer != null) ? configuration.cloudServer : ""); + RRMapDraw rrMap = RRMapDraw.loadImage(new ByteArrayInputStream(mapData)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (logger.isDebugEnabled()) { + String fn = ConfigConstants.getUserDataFolder() + File.separator + BINDING_ID + File.separator + + map; + CloudUtil.writeBytesToFileNio(mapData, + fn + (new SimpleDateFormat("yyyyMMdd-HHss")).format(new Date()) + ".rrmap"); + logger.debug("Mapdata saved to {}", fn + ".rrmap"); + } + ImageIO.write(rrMap.getImage(MAP_SCALE), "jpg", baos); + byte[] byteArray = baos.toByteArray(); + if (byteArray != null && byteArray.length > 0) { + return new RawType(byteArray, "image/jpeg"); + } else { + logger.debug("Mapdata empty removing image"); + return UnDefType.UNDEF; + } + } catch (MiCloudException e) { + logger.debug("Error getting data from Xiaomi cloud. Mapdata could not be updated: {}", e.getMessage()); + } catch (IOException e) { + logger.debug("Mapdata could not be updated: {}", e.getMessage()); + } + } else { + logger.debug("Not connected to Xiaomi cloud. Cannot retreive new map: {}", map); + } + return UnDefType.UNDEF; + } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java new file mode 100644 index 0000000000000..33b3d3595f331 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java @@ -0,0 +1,368 @@ +/** + * Copyright (c) 2010-2020 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.robot; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Stroke; +import java.awt.geom.AffineTransform; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Line2D; +import java.awt.geom.Path2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.AffineTransformOp; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Base64; + +import javax.imageio.ImageIO; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Draws the vacuum map file to an image + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class RRMapDraw { + + private static final Color COLOR_MAP_INSIDE = new Color(32, 115, 185); + private static final Color COLOR_MAP_OUTSIDE = new Color(19, 87, 148); + private static final Color COLOR_MAP_WALL = new Color(100, 196, 254); + private static final Color COLOR_GREY_WALL = new Color(93, 109, 126); + private static final Color COLOR_PATH = new Color(147, 194, 238); + private static final Color COLOR_ZONES = new Color(0xAD, 0xD8, 0xFF, 0x8F); + private static final Color COLOR_NO_GO_ZONES = new Color(255, 33, 55, 127); + private static final Color COLOR_CHARGER_HALO = new Color(0x66, 0xfe, 0xda, 0x7f); + private static final Color COLOR_ROBO = new Color(75, 235, 149); + private static final Color COLOR_SCAN = new Color(0xDF, 0xDF, 0xDF); + private static final Color ROOM1 = new Color(240, 178, 122); + private static final Color ROOM2 = new Color(133, 193, 233); + private static final Color ROOM3 = new Color(217, 136, 128); + private static final Color ROOM4 = new Color(52, 152, 219); + private static final Color ROOM5 = new Color(205, 97, 85); + private static final Color ROOM6 = new Color(243, 156, 18); + private static final Color ROOM7 = new Color(88, 214, 141); + private static final Color ROOM8 = new Color(245, 176, 65); + private static final Color ROOM9 = new Color(0xFc, 0xD4, 0x51); + private static final Color ROOM10 = new Color(72, 201, 176); + private static final Color ROOM11 = new Color(84, 153, 199); + private static final Color ROOM12 = new Color(133, 193, 233); + private static final Color ROOM13 = new Color(245, 176, 65); + private static final Color ROOM14 = new Color(82, 190, 128); + private static final Color ROOM15 = new Color(72, 201, 176); + private static final Color ROOM16 = new Color(165, 105, 189); + private static final Color[] ROOM_COLORS = { ROOM1, ROOM2, ROOM3, ROOM4, ROOM5, ROOM6, ROOM7, ROOM8, ROOM9, ROOM10, + ROOM11, ROOM12, ROOM13, ROOM14, ROOM15, ROOM16 }; + private boolean multicolor = false; + + Dimension size = new Dimension(); + private RRMapFileParser rmfp; + + public RRMapDraw(RRMapFileParser rmfp) { + this.rmfp = rmfp; + } + + public void setRRFileDecoder(RRMapFileParser rmfp) { + this.rmfp = rmfp; + } + + public int getWidth() { + return rmfp.getImgWidth(); + } + + public int getHeight() { + return rmfp.getImgHeight(); + } + + /** + * load Gzipped RR inputstream + * + * @throws IOException + */ + public static RRMapDraw loadImage(InputStream is) throws IOException { + byte[] inputdata = RRMapFileParser.readRRMapFile(is); + RRMapFileParser rf = new RRMapFileParser(inputdata); + return new RRMapDraw(rf); + } + + /** + * load Gzipped RR file + * + * @throws IOException + */ + public static RRMapDraw loadImage(File file) throws IOException { + return loadImage(new FileInputStream(file)); + } + + /** + * draws the map from the individual pixels + */ + private void drawMap(Graphics2D g2d, float scale) { + Stroke stroke = new BasicStroke(1.1f * scale); + g2d.setStroke(stroke); + for (int y = 0; y < rmfp.getImgHeight() - 1; y++) { + for (int x = 0; x < rmfp.getImgWidth() + 1; x++) { + byte walltype = rmfp.getImage()[x + rmfp.getImgWidth() * y]; + switch (walltype & 0xFF) { + case 0x00: + g2d.setColor(COLOR_MAP_OUTSIDE); + break; + case 0x01: + g2d.setColor(COLOR_MAP_WALL); + break; + case 0xFF: + g2d.setColor(COLOR_MAP_INSIDE); + break; + case 0x07: + g2d.setColor(COLOR_SCAN); + break; + default: + int obstacle = (walltype & 0x07); + int mapId = (walltype & 0xFF) >>> 3; + switch (obstacle) { + case 0: + g2d.setColor(COLOR_GREY_WALL); + break; + case 1: + g2d.setColor(Color.BLACK); + break; + case 7: + g2d.setColor(ROOM_COLORS[Math.round(mapId / 2)]); + multicolor = true; + break; + default: + g2d.setColor(Color.WHITE); + break; + } + } + float xPos = scale * (rmfp.getImgWidth() - x); + float yP = scale * y; + g2d.draw(new Line2D.Float(xPos, yP, xPos, yP)); + } + } + } + + /** + * draws the vacuum path + * + * @param scale + */ + private void drawPath(Graphics2D g2d, float scale) { + Stroke stroke = new BasicStroke(0.5f * scale); + g2d.setStroke(stroke); + for (Integer pathType : rmfp.getPaths().keySet()) { + switch (pathType) { + case RRMapFileParser.PATH: + if (!multicolor) { + g2d.setColor(COLOR_PATH); + } else { + g2d.setColor(Color.WHITE); + } + break; + case RRMapFileParser.GOTO_PATH: + g2d.setColor(Color.GREEN); + break; + case RRMapFileParser.GOTO_PREDICTED_PATH: + g2d.setColor(Color.YELLOW); + break; + default: + g2d.setColor(Color.CYAN); + } + float prvX = 0; + float prvY = 0; + for (Float[] point : rmfp.getPaths().get(pathType)) { + float x = point[0] * scale; + float y = point[1] * scale; + if (prvX > 1) { + g2d.draw(new Line2D.Float(prvX, prvY, x, y)); + } + prvX = x; + prvY = y; + } + } + } + + private void drawZones(Graphics2D g2d, float scale) { + for (Float[] point : rmfp.getZones()) { + float x = point[0] * scale; + float y = point[1] * scale; + float x1 = point[2] * scale; + float y1 = point[3] * scale; + float sx = Math.min(x, x1); + float w = Math.max(x, x1) - sx; + float sy = Math.min(y, y1); + float h = Math.max(y, y1) - sy; + g2d.setColor(COLOR_ZONES); + g2d.fill(new Rectangle2D.Float(sx, sy, w, h)); + } + } + + private void drawNoGo(Graphics2D g2d, float scale) { + for (Integer area : rmfp.getAreas().keySet()) { + for (Float[] point : rmfp.getAreas().get(area)) { + float x = point[0] * scale; + float y = point[1] * scale; + float x1 = point[2] * scale; + float y1 = point[3] * scale; + float x2 = point[4] * scale; + float y2 = point[5] * scale; + float x3 = point[6] * scale; + float y3 = point[7] * scale; + Path2D noGo = new Path2D.Float(); + noGo.moveTo(x, y); + noGo.lineTo(x1, y1); + noGo.lineTo(x2, y2); + noGo.lineTo(x3, y3); + noGo.lineTo(x, y); + g2d.setColor(COLOR_NO_GO_ZONES); + g2d.fill(noGo); + g2d.setColor(area == 9 ? Color.RED : Color.WHITE); + g2d.draw(noGo); + } + } + } + + private void drawWalls(Graphics2D g2d, float scale) { + Stroke stroke = new BasicStroke(3 * scale); + g2d.setStroke(stroke); + for (Float[] point : rmfp.getWalls()) { + float x = point[0] * scale; + float y = point[1] * scale; + float x1 = point[2] * scale; + float y1 = point[3] * scale; + g2d.setColor(Color.RED); + g2d.draw(new Line2D.Float(x, y, x1, y1)); + } + } + + private void drawRobo(Graphics2D g2d, float scale) { + float radius = 3 * scale; + Stroke stroke = new BasicStroke(2 * scale); + g2d.setStroke(stroke); + g2d.setColor(COLOR_CHARGER_HALO); + drawCircle(g2d, rmfp.getChargerX() * scale, rmfp.getChargerY() * scale, radius); + drawCenteredImg(g2d, scale / 8, + "iVBORw0KGgoAAAANSUhEUgAAACoAAAAqCAMAAADyHTlpAAADAFBMVEVHcExF5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o7////w/fa69tVm6qL1/vlt66br/PNf6p7+//7o/PGE7rR37ay39dOL77nM+OCh8sbB99n2/vlH5o9e6Z2c8sPk++/j++5s66Vj6qBg6p6i88dn66Nq66VY6Zq99td67a6S8L30/vjb+ul57a2C7rNW6Jiv9M5Q55UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmUTLUAAAALnRSTlMAJPkvN+wI+4DP1eoxP47t7v4GrpmcB6+Ptj7w+vMJ9Lm11DIlMI0m0DUuM/G06wfbVgAAAfxJREFUeNqNletzmzAMwGVhXg0NTbLkbtl12fvW/f//ya7Xy7h82Nruwa5p8yhhgPGAFrCN6VWfbOmHZEtCNkCSMUX/ZZ6S4SvkrneQbETceE66ELY/aLzXo8bru1M5Blw6IWvt9cIa8LmvkOAPbqcJU7wS5yPoJYi55PUs+tBDwmSNuYBSew69MqVRxWIV3R7BEzKySe3VsjTRrUWSNtneInu41vCNhuTZ0X27jb9VBzD+aUmYCPvvXyqvb4+1pBuJGv618OrddcjTgoSppLryCtRVqwn0Oqtz037tAp2ZHTKvypMqZ5ohnejJ0UEpGsW1ngR2oxjW6OtJuZGrLsOdtJ/XJGQqukPp9M5NTYJpVNKmLKVMJHnSrB+z3RaXYQ9Zpyhv12i062GXJHH7a1GhAuZjCCvWkIVV6JVrVsmotpOxQMIxbjrNUh+D4C9RvcFxB61rgHJqTczUAsKRlsxdDNVrfwp1JPwJEYa6+B0SZkUDr7ayrswYnank9rw0mEtJWUCU/FbI5WXlI7BF5eefBdkpm80ewp0EgjKONWRyUp6qHLlk9b7tbdIlVxFvxhu3nOa/4EwlN7e58F9QctY73S541qSmvPYi6CODd5k84It5e/VCAy7zKFTfArYHMuiQf3c79rzHyF/1vVvF0E3TwcHyYJ+496Ypj5P/uAmtfUpJqE0AAAAASUVORK5CYII=", + rmfp.getChargerX() * scale, rmfp.getChargerY() * scale); + radius = 3 * scale; + g2d.setColor(COLOR_ROBO); + drawCircle(g2d, rmfp.getRoboX() * scale, rmfp.getRoboY() * scale, radius); + if (scale > 1.5) { + drawCenteredImg(g2d, scale / 15, + "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", + rmfp.getRoboX() * scale, rmfp.getRoboY() * scale); + } + } + + private void drawCircle(Graphics2D g2d, float x, float y, float radius) { + g2d.draw(new Ellipse2D.Double(x - radius, y - radius, 2.0 * radius, 2.0 * radius)); + } + + private void drawCenteredImg(Graphics2D g2d, float scale, String imgData, float x, float y) { + try { + BufferedImage addImg = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(imgData))); + int xpos = Math.round(x - (addImg.getWidth() / 2 * scale)); + int ypos = Math.round(y - (addImg.getHeight() / 2 * scale)); + AffineTransform at = new AffineTransform(); + at.scale(scale, scale); + AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); + g2d.drawImage(addImg, scaleOp, xpos, ypos); + } catch (IOException e) { + // ignore + } + } + + private void drawGoTo(Graphics2D g2d, float scale) { + float x = rmfp.getGotoX() * scale; + float y = rmfp.getGotoY() * scale; + if (!(x == 0 && y == 0)) { + g2d.setStroke(new BasicStroke()); + g2d.setColor(Color.YELLOW); + int x3[] = { (int) x, (int) (x - 2 * scale), (int) (x + 2 * scale) }; + int y3[] = { (int) y, (int) (y - 5 * scale), (int) (y - 5 * scale) }; + g2d.fill(new Polygon(x3, y3, 3)); + + } + } + + private void drawOpenHabRocks(Graphics2D g2d, int width, int height, float scale) { + // easter egg gift : + int offset = 5; + int textPos = 55; + try { + BufferedImage ohLogo = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode( + "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"))); + textPos = (int) (ohLogo.getWidth() * scale / 2 + offset); + AffineTransform at = new AffineTransform(); + at.scale(scale / 2, scale / 2); + AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); + g2d.drawImage(ohLogo, scaleOp, offset, height - (int) (ohLogo.getHeight() * scale / 2) - offset); + } catch (IOException e) { + // no joy + } + Font font = new Font("TimesRoman", Font.BOLD, 14); + g2d.setFont(font); + String message = "Openhab rocks your Xiaomi vacuum!"; + FontMetrics fontMetrics = g2d.getFontMetrics(); + int stringWidth = fontMetrics.stringWidth(message); + if ((stringWidth + textPos) > rmfp.getImgWidth() * scale) { + font = new Font("TimesRoman", Font.BOLD, + (int) Math.floor(14 * (rmfp.getImgWidth() * scale - textPos - offset) / stringWidth)); + g2d.setFont(font); + } + int stringHeight = fontMetrics.getAscent(); + g2d.setPaint(Color.white); + g2d.drawString(message, textPos, height - offset - stringHeight / 2); + } + + public BufferedImage getImage(float scale) { + int width = (int) Math.floor(rmfp.getImgWidth() * scale); + int height = (int) Math.floor(rmfp.getImgHeight() * scale); + BufferedImage bi = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR); + Graphics2D g2d = bi.createGraphics(); + AffineTransform tx = AffineTransform.getScaleInstance(-1, -1); + tx.translate(-width, -height); + g2d.setTransform(tx); + drawMap(g2d, scale); + drawZones(g2d, scale); + drawNoGo(g2d, scale); + drawWalls(g2d, scale); + drawPath(g2d, scale); + drawRobo(g2d, scale); + drawGoTo(g2d, scale); + g2d = bi.createGraphics(); + drawOpenHabRocks(g2d, width, height, scale); + return bi; + + } + + public boolean writePic(String filename, String formatName, float scale) throws IOException { + return ImageIO.write(getImage(scale), formatName, new File(filename)); + } + + @Override + public String toString() { + return rmfp.toString(); + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java new file mode 100644 index 0000000000000..fbd54a5db01d7 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java @@ -0,0 +1,415 @@ +/** + * Copyright (c) 2010-2020 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.robot; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.miio.internal.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link RRMapFileParser} is used to parse the RR map file format created by Xiaomi / RockRobo vacuum + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class RRMapFileParser { + public static final int CHARGER = 1; + public static final int IMAGE = 2; + public static final int PATH = 3; + public static final int GOTO_PATH = 4; + public static final int GOTO_PREDICTED_PATH = 5; + public static final int CURRENTLY_CLEANED_ZONES = 6; + public static final int GOTO_TARGET = 7; + public static final int ROBOT_POSITION = 8; + public static final int NO_GO_AREAS = 9; + public static final int VIRTUAL_WALLS = 10; + public static final int BLOCKS = 11; + public static final int MFBZS_AREA = 12; + public static final int OBSTACLES = 13; + public static final int DIGEST = 1024; + public static final int HEADER = 0x7272; + + public static final String PATH_POINT_LENGTH = "pointLength"; + public static final String PATH_POINT_SIZE = "pointSize"; + public static final String PATH_ANGLE = "angle"; + + private static final float MM = 50.0f; + + private byte[] image = new byte[] { 0 }; + private int majorVersion; + private int minorVersion; + private int mapIndex; + private int mapSequence; + private boolean isValid; + + private int imgHeight; + private int imgWidth; + private int imageSize; + private int top; + private int left; + private int offset; + + private int chargerX; + private int chargerY; + private int roboX; + private int roboY; + private int roboA; + private float gotoX = 0; + private float gotoY = 0; + private Map> paths = new HashMap<>(); + private Map> pathsDetails = new HashMap<>(); + private Map> areas = new HashMap<>(); + private ArrayList walls = new ArrayList(); + private ArrayList zones = new ArrayList(); + private ArrayList obstacles = new ArrayList(); + private byte[] blocks = new byte[0]; + + private final Logger logger = LoggerFactory.getLogger(RRMapFileParser.class); + + public RRMapFileParser(byte[] raw) { + boolean printBlockDetails = false; + + int mapHeaderLength = getUInt16(raw, 0x02); + int mapDataLength = getUInt32LE(raw, 0x04); + + this.majorVersion = getUInt16(raw, 0x08); + this.minorVersion = getUInt16(raw, 0x0A); + this.mapIndex = getUInt32LE(raw, 0x0C); + this.mapSequence = getUInt32LE(raw, 0x10); + + int blockStartPos = getUInt16(raw, 0x02); // main header length + while (blockStartPos < raw.length) { + int blockHeaderLength = getUInt16(raw, blockStartPos + 0x02); + byte[] header = getBytes(raw, blockStartPos, blockHeaderLength); + int blocktype = getUInt16(header, 0x00); + int blockDataLength = getUInt32LE(header, 0x04); + int blockDataStart = blockStartPos + blockHeaderLength; + byte[] data = getBytes(raw, blockDataStart, blockDataLength); + + switch (blocktype) { + case CHARGER: + this.chargerX = getUInt32LE(raw, blockStartPos + 0x08); + this.chargerY = getUInt32LE(raw, blockStartPos + 0x0C); + break; + case IMAGE: + this.imageSize = blockDataLength;// (getUInt32LE(raw, blockStartPos + 0x04)); + if (blockHeaderLength > 0x1C) { + logger.debug("block 2 unknown value @pos 8: {}", getUInt32LE(header, 0x08)); + } + this.top = getUInt32LE(header, blockHeaderLength - 16); + this.left = getUInt32LE(header, blockHeaderLength - 12); + this.imgHeight = (getUInt32LE(header, blockHeaderLength - 8)); + this.imgWidth = getUInt32LE(header, blockHeaderLength - 4); + this.offset = imgWidth + left; + this.image = data; + break; + case ROBOT_POSITION: + this.roboX = getUInt32LE(data, 0x00); + this.roboY = getUInt32LE(data, 0x04); + if (blockDataLength > 8) { // model S6 + this.roboA = getUInt32LE(data, 0x08); + } + break; + case PATH: + case GOTO_PATH: + case GOTO_PREDICTED_PATH: + ArrayList path = new ArrayList(); + Map detail = new HashMap(); + int pairs = getUInt32LE(header, 0x04) / 4; + detail.put(PATH_POINT_LENGTH, getUInt32LE(header, 0x08)); + detail.put(PATH_POINT_SIZE, getUInt32LE(header, 0x0C)); + detail.put(PATH_ANGLE, getUInt32LE(header, 0x10)); + for (int pathpair = 0; pathpair < pairs; pathpair++) { + Float x = offset - (getUInt16(getBytes(raw, blockDataStart + pathpair * 4, 2))) / MM; + Float y = getUInt16(getBytes(raw, blockDataStart + pathpair * 4 + 2, 2)) / MM - top; + path.add(new Float[] { x, y }); + } + paths.put(blocktype, path); + pathsDetails.put(blocktype, detail); + break; + case CURRENTLY_CLEANED_ZONES: + int zonePairs = getUInt16(header, 0x08); + for (int zonePair = 0; zonePair < zonePairs; zonePair++) { + Float x0 = offset - (getUInt16(raw, blockDataStart + zonePair * 8)) / MM; + Float y0 = getUInt16(raw, blockDataStart + zonePair * 8 + 2) / MM - top; + Float x1 = offset - (getUInt16(raw, blockDataStart + zonePair * 8 + 4)) / MM; + Float y1 = getUInt16(raw, blockDataStart + zonePair * 8 + 6) / MM - top; + zones.add(new Float[] { x0, y0, x1, y1 }); + } + break; + case GOTO_TARGET: + this.gotoX = offset - getUInt16(data, 0x00) / MM; + this.gotoY = getUInt16(data, 0x02) / MM - top; + break; + case DIGEST: + isValid = Arrays.equals(data, sha1Hash(getBytes(raw, 0, mapHeaderLength + mapDataLength - 20))); + break; + case VIRTUAL_WALLS: + int wallPairs = getUInt16(header, 0x08); + for (int wallPair = 0; wallPair < wallPairs; wallPair++) { + Float x0 = offset - (getUInt16(raw, blockDataStart + wallPair * 8)) / MM; + Float y0 = getUInt16(raw, blockDataStart + wallPair * 8 + 2) / MM - top; + Float x1 = offset - (getUInt16(raw, blockDataStart + wallPair * 8 + 4)) / MM; + Float y1 = getUInt16(raw, blockDataStart + wallPair * 8 + 6) / MM - top; + walls.add(new Float[] { x0, y0, x1, y1 }); + } + break; + case NO_GO_AREAS: + case MFBZS_AREA: + int areaPairs = getUInt16(header, 0x08); + ArrayList area = new ArrayList(); + for (int areaPair = 0; areaPair < areaPairs; areaPair++) { + Float x0 = offset - (getUInt16(raw, blockDataStart + areaPair * 16)) / MM; + Float y0 = getUInt16(raw, blockDataStart + areaPair * 16 + 2) / MM - top; + Float x1 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 4)) / MM; + Float y1 = getUInt16(raw, blockDataStart + areaPair * 16 + 6) / MM - top; + Float x2 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 8)) / MM; + Float y2 = getUInt16(raw, blockDataStart + areaPair * 16 + 10) / MM - top; + Float x3 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 12)) / MM; + Float y3 = getUInt16(raw, blockDataStart + areaPair * 16 + 14) / MM - top; + area.add(new Float[] { x0, y0, x1, y1, x2, y2, x3, y3 }); + } + areas.put(Integer.valueOf(blocktype & 0xFF), area); + break; + case OBSTACLES: + int obstaclePairs = getUInt16(header, 0x08); + for (int obstaclePair = 0; obstaclePair < obstaclePairs; obstaclePair++) { + int x0 = getUInt16(data, obstaclePair * 5 + 0); + int y0 = getUInt16(data, obstaclePair * 5 + 2); + int u = data[obstaclePair * 5 + 0] & 0xFF; + obstacles.add(new int[] { x0, y0, u }); + } + break; + case BLOCKS: + int blocksPairs = getUInt16(header, 0x08); + blocks = getBytes(data, 0, blocksPairs); + break; + default: + logger.info("Unknown blocktype (pls report to author)"); + printBlockDetails = true; + } + if (logger.isTraceEnabled() || printBlockDetails) { + logger.info("Blocktype: {}", Integer.toString(blocktype)); + logger.info("Header len: {} data len: {} ", Integer.toString(blockHeaderLength), + Integer.toString(blockDataLength)); + logger.info("H: {}", Utils.getSpacedHex(header)); + if (blockDataLength > 0) { + logger.info("D: {}", (blockDataLength < 60 ? Utils.getSpacedHex(data) + : Utils.getSpacedHex(getBytes(data, 0, 60)))); + } + printBlockDetails = false; + } + blockStartPos = blockStartPos + blockDataLength + (header[2] & 0xFF); + } + } + + public static byte[] readRRMapFile(File file) throws IOException { + return readRRMapFile(new FileInputStream(file)); + } + + public static byte[] readRRMapFile(InputStream is) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPInputStream in = new GZIPInputStream(is)) { + int bufsize = 1024; + byte[] buf = new byte[bufsize]; + int readbytes = 0; + readbytes = in.read(buf); + while (readbytes != -1) { + baos.write(buf, 0, readbytes); + readbytes = in.read(buf); + } + baos.flush(); + return baos.toByteArray(); + } + } + + private byte[] getBytes(byte[] raw, int pos, int len) { + return java.util.Arrays.copyOfRange(raw, pos, pos + len); + } + + private int getUInt32LE(byte[] bytes, int pos) { + int value = bytes[0 + pos] & 0xFF; + value |= (bytes[1 + pos] << 8) & 0xFFFF; + value |= (bytes[2 + pos] << 16) & 0xFFFFFF; + value |= (bytes[3 + pos] << 24) & 0xFFFFFFFF; + return value; + } + + private int getUInt16(byte[] bytes) { + return getUInt16(bytes, 0); + } + + private int getUInt16(byte[] bytes, int pos) { + int value = bytes[0 + pos] & 0xFF; + value |= (bytes[1 + pos] << 8) & 0xFFFF; + return value; + } + + @Override + public String toString() { + String s = "RR Map:\tMajor Version: " + majorVersion + " Minor version: " + minorVersion + " Map Index: " + + mapIndex + " Map Sequence: " + mapSequence + " \r\n"; + s += "Image:\tsize: " + Integer.toString(imageSize) + "\ttop: " + Integer.toString(top) + "\tleft: " + + Integer.toString(left) + " height: " + Integer.toString(imgHeight) + " width: " + + Integer.toString(imgWidth) + "\r\n"; + s += "Charger pos:\tX: " + Float.toString(getChargerX()) + "\tY: " + Float.toString(getChargerY()) + "\r\n"; + s += "Robo pos:\tX: " + Float.toString(getRoboX()) + "\tY: " + Float.toString(getRoboY()) + " Angle: " + + Float.toString(getRoboA()) + "\r\n"; + s += "Goto:\tX: " + Float.toString(getGotoX()) + "\tY: " + Float.toString(getGotoY()) + "\r\n"; + for (Integer area : areas.keySet()) { + s += (area == NO_GO_AREAS ? "No Go zones:\t" : "MFBZS zones:\t") + Integer.toString(areas.get(area).size()) + + "\r\n"; + } + s += "Walls:\t" + Integer.toString(walls.size()) + "\r\n"; + s += "Obstacles:\t" + Integer.toString(obstacles.size()) + "\r\n"; + s += "Blocks:\t" + Integer.toString(blocks.length) + "\r\n"; + s += "Paths:"; + for (Integer p : pathsDetails.keySet()) { + s += "\r\nPath type:\t" + Integer.toString(p); + for (String detail : pathsDetails.get(p).keySet()) { + s += " " + detail + ": " + Integer.toString(pathsDetails.get(p).get(detail)); + } + } + return s; + } + + /** + * Compute SHA-1 hash value for the byte array + * + * @param inBytes ByteArray to be hashed + * @return hash value + */ + public static byte[] sha1Hash(byte[] inBytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + return md.digest(inBytes); + } catch (NoSuchAlgorithmException e) { + return new byte[] { 0x00 }; + } + } + + public int getMajorVersion() { + return majorVersion; + } + + public int getMinorVersion() { + return minorVersion; + } + + public int getMapIndex() { + return mapIndex; + } + + public int getMapSequence() { + return mapSequence; + } + + public boolean isValid() { + return isValid; + } + + public byte[] getImage() { + return image; + } + + public int getImageSize() { + return imageSize; + } + + public int getImgHeight() { + return imgHeight; + } + + public int getImgWidth() { + return imgWidth; + } + + public int getTop() { + return top; + } + + public int getLeft() { + return left; + } + + public ArrayList getZones() { + return zones; + } + + public float getRoboX() { + return offset - (roboX / MM); + } + + public float getRoboY() { + return roboY / MM - top; + } + + public float getChargerX() { + return offset - (chargerX / MM); + } + + public float getChargerY() { + return chargerY / MM - top; + } + + public float getGotoX() { + return gotoX; + } + + public float getGotoY() { + return gotoY; + } + + public int getRoboA() { + return roboA; + } + + public Map> getPaths() { + return paths; + } + + public Map> getPathsDetails() { + return pathsDetails; + } + + public ArrayList getWalls() { + return walls; + } + + public Map> getAreas() { + return areas; + } + + public ArrayList getObstacles() { + return obstacles; + } + + public byte[] getBlocks() { + return blocks; + } + +} diff --git a/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/binding/binding.xml b/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/binding/binding.xml index 678675d57def7..0b5bbd7884455 100644 --- a/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/binding/binding.xml +++ b/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/binding/binding.xml @@ -1,8 +1,26 @@ - Xiaomi Mi IO Binding Binding for Xiaomi Mi IO devices like Mi Robot Vacuum Marcel Verpaalen + + + + + Xiaomi cloud username. Typically your email + false + + + + false + + + + Xiaomi server country(s) (e.g. sg,de). Separate multiple servers with comma + false + + + diff --git a/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/config/config.xml b/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/config/config.xml index dfaadb22b9900..8bc3b453637a9 100644 --- a/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/config/config.xml +++ b/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/config/config.xml @@ -35,5 +35,9 @@ 15000 true + + + true + diff --git a/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/thing/vacuumThing.xml b/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/thing/vacuumThing.xml index b4499eee2d2fc..79572f4eb6ef3 100644 --- a/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/thing/vacuumThing.xml +++ b/bundles/org.openhab.binding.miio/src/main/resources/ESH-INF/thing/vacuumThing.xml @@ -82,6 +82,7 @@ + @@ -315,4 +316,9 @@ + + Image + + + diff --git a/bundles/org.openhab.binding.miio/src/test/java/org/openhab/binding/miio/internal/RoboMapViewer.java b/bundles/org.openhab.binding.miio/src/test/java/org/openhab/binding/miio/internal/RoboMapViewer.java new file mode 100644 index 0000000000000..e4c6805a5bed4 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/test/java/org/openhab/binding/miio/internal/RoboMapViewer.java @@ -0,0 +1,330 @@ +/** + * Copyright (c) 2010-2020 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; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.HeadlessException; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.file.InvalidPathException; + +import javax.swing.Box; +import javax.swing.JButton; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextArea; +import javax.swing.SwingConstants; +import javax.swing.filechooser.FileNameExtensionFilter; +import javax.swing.filechooser.FileSystemView; + +//import org.apache.log4j.PropertyConfigurator; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.Ignore; +import org.openhab.binding.miio.internal.robot.RRMapDraw; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Offline map vacuum viewer application + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +public class RoboMapViewer extends JFrame { + + private static final String TITLE = "Offline Xiaomi Robot Radar Map Viewer"; + final private JFrame parent; + final private RRDrawPanel rrDrawPanel = new RRDrawPanel(); + final private JTextArea textArea = new JTextArea(); + final private JLabel statusbarL = new JLabel(); + final private JLabel statusbarR = new JLabel("1.0x"); + + private float scale = 1.0f; + private @Nullable File file; + + private final Logger logger = LoggerFactory.getLogger(RoboMapViewer.class); + private static final long serialVersionUID = 2623447051590306992L; + + @Ignore + public static void main(String args[]) { + System.setProperty("swing.defaultlaf", "javax.swing.plaf.metal.MetalLookAndFeel"); + RoboMapViewer vc = new RoboMapViewer(); + vc.setVisible(true); + } + + public RoboMapViewer() { + super(TITLE); + parent = this; + setSize(500, 600); + setDefaultCloseOperation(EXIT_ON_CLOSE); + + textArea.setEditable(false); + Container c = getContentPane(); + + final JButton openButton = new JButton("Open"); + final JButton scalePButton = new JButton("+"); + final JButton scaleMButton = new JButton("-"); + final JButton previousButton = new JButton("<<"); + final JButton nextButton = new JButton(">>"); + previousButton.setToolTipText("Cyles to the previous map file"); + nextButton.setToolTipText("Cyles to the next map file"); + scaleMButton.setToolTipText("Zoom out"); + scalePButton.setToolTipText("Zoom in"); + + Box north = Box.createHorizontalBox(); + north.setBackground(Color.GRAY); + north.setForeground(Color.BLUE); + north.add(openButton); + north.add(previousButton); + north.add(nextButton); + north.add(Box.createHorizontalGlue()); + north.add(scalePButton); + north.add(scaleMButton); + c.add(north, "First"); + + JScrollPane mapView = new JScrollPane(rrDrawPanel); + JSplitPane middle = new JSplitPane(SwingConstants.HORIZONTAL, mapView, new JScrollPane(textArea)); + middle.setResizeWeight(.65d); + c.add(middle, "Center"); + + final JPanel statusbar = new JPanel(new BorderLayout()); + statusbar.add(BorderLayout.WEST, Box.createRigidArea(new Dimension(3, 0))); + statusbar.add(statusbarL); + statusbar.add(BorderLayout.EAST, statusbarR); + c.add(statusbar, "Last"); + + // TODO: have the map details of the coord with mouse click/moveover + rrDrawPanel.addMouseListener((new MouseListener() { + @Override + public void mouseReleased(@Nullable MouseEvent e) { + } + + @Override + public void mousePressed(@Nullable MouseEvent e) { + if (e != null) { + logger.info("Click @ {}", e.getPoint()); + } + } + + @Override + public void mouseExited(@Nullable MouseEvent e) { + } + + @Override + public void mouseEntered(@Nullable MouseEvent e) { + } + + @Override + public void mouseClicked(@Nullable MouseEvent e) { + } + })); + + scalePButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(@Nullable ActionEvent ae) { + scale = scale + 0.5f; + final File f = file; + if (f != null) { + loadfile(f); + } + } + }); + scaleMButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(@Nullable ActionEvent ae) { + scale = scale < 1.5 ? 1 : scale - 0.5f; + final File f = file; + if (f != null) { + loadfile(f); + } + } + }); + + nextButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(@Nullable ActionEvent ae) { + boolean loadNextFile = false; + final File f = file; + if (f == null || f.getParentFile() == null) { + return; + } + try { + for (final File fileEntry : f.getParentFile().listFiles()) { + if (isRRFile(fileEntry) && loadNextFile) { + file = fileEntry; + loadfile(fileEntry); + break; + } + if (fileEntry.getName().contentEquals(f.getName())) { + loadNextFile = true; + } + } + } catch (SecurityException e) { + logger.debug("Error finding next file:{}", e); + } + } + }); + + previousButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(@Nullable ActionEvent ae) { + File previousFile = null; + final File f = file; + if (f == null || f.getParentFile() == null) { + return; + } + try { + for (final File fileEntry : f.getParentFile().listFiles()) { + if (fileEntry.getName().contentEquals(f.getName())) { + if (previousFile != null) { + file = previousFile; + loadfile(previousFile); + break; + } + } + if (isRRFile(fileEntry)) { + previousFile = fileEntry; + } + } + } catch (SecurityException e) { + logger.debug("Error finding next file:{}", e); + } + } + + }); + + openButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(@Nullable ActionEvent ae) { + JFileChooser chooser = new JFileChooser("images"); + chooser.setFileFilter(new FileNameExtensionFilter("Robot Radar map (*.rrmap,*.gz)", "rrmap", "gz")); + try { + int option = chooser.showOpenDialog(parent); + if (option == JFileChooser.APPROVE_OPTION) { + final File f = chooser.getSelectedFile(); + file = f; + loadfile(f); + } else { + statusbarL.setText("You cancelled."); + } + } catch (HeadlessException e) { + logger.debug("{}", e); + } + } + }); + + loadFirstFile(); + } + + protected boolean isRRFile(File fileEntry) { + boolean isRRFile = fileEntry.getName().toLowerCase().endsWith(".rrmap") + || fileEntry.getName().toLowerCase().endsWith(".gz"); + return isRRFile; + } + + private void loadFirstFile() { + try { + File myDocs = FileSystemView.getFileSystemView().getDefaultDirectory(); + for (final File fileEntry : myDocs.listFiles()) { + if (isRRFile(fileEntry)) { + file = fileEntry; + loadfile(fileEntry); + break; + } + } + } catch (SecurityException | InvalidPathException e) { + logger.debug("Error finding first file:{}", e); + } + + } + + private void updateStatusLine() { + final File f = this.file; + if (f != null) { + statusbarL.setText(f.getName()); + } else { + statusbarL.setText(""); + } + statusbarR.setText("zoom: " + Float.toString(scale) + "x "); + } + + private void loadfile(File file) { + try { + logger.info("Loading " + file.getPath()); + RRMapDraw rrMap = RRMapDraw.loadImage(file); + textArea.setText(rrMap.toString()); + parent.setTitle(TITLE + " " + file.getName()); + rrDrawPanel.setImage(rrMap.getImage(scale)); + rrMap.writePic(file.getPath() + ".jpg", "JPEG", scale); + updateStatusLine(); + rrDrawPanel.setSize(rrMap.getWidth(), rrMap.getHeight()); + } catch (Exception e) { + textArea.append("Error while loading: " + e.getMessage()); + logger.info("Error while loading {}", e); + } + } +} + +/** + * Robot Radar Map map panel + * + * @author Marcel Verpaalen - Initial contribution + */ +@NonNullByDefault +class RRDrawPanel extends JPanel { + private static final long serialVersionUID = 8791558011928073284L; + private @Nullable BufferedImage image; + Dimension size = new Dimension(); + + @Override + protected void paintComponent(@Nullable Graphics g) { + super.paintComponent(g); + final BufferedImage image = this.image; + if (g != null && image != null) { + g.drawImage(image, 0, 0, this); + } + } + + @Override + public Dimension getPreferredSize() { + return size; + } + + public void setImage(BufferedImage bi) { + image = bi; + setComponentSize(); + repaint(); + } + + private void setComponentSize() { + final BufferedImage image = this.image; + if (image != null) { + size.width = image.getWidth(); + size.height = image.getHeight(); + revalidate(); // signal parent/scrollpane + } + } +} From 84e113ad3098296f15290512d085c8df24e5080e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 30 Mar 2020 07:20:06 +0200 Subject: [PATCH 02/10] [miio] fix updating basic devices (#7254) Signed-off-by: Marcel Verpaalen --- .../binding/miio/internal/handler/MiIoBasicHandler.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java index ffa5d9992aabf..7e1a819bc67db 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java @@ -425,17 +425,17 @@ private void updateChannel(@Nullable MiIoBasicChannel basicChannel, String param } try { switch (basicChannel.getType().toLowerCase()) { - case "Number": + case "number": updateState(basicChannel.getChannel(), new DecimalType(val.getAsBigDecimal())); break; - case "String": + case "string": updateState(basicChannel.getChannel(), new StringType(val.getAsString())); break; - case "Switch": + case "switch": updateState(basicChannel.getChannel(), val.getAsString().toLowerCase().equals("on") || val.getAsString().toLowerCase().equals("true") ? OnOffType.ON : OnOffType.OFF); break; - case "Color": + case "color": Color rgb = new Color(val.getAsInt()); HSBType hsb = HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue()); updateState(basicChannel.getChannel(), hsb); From 75bcaf7c0723338514c62a4bdb7635e4cdb56cc2 Mon Sep 17 00:00:00 2001 From: Marcel Verpaalen Date: Wed, 1 Apr 2020 16:11:01 +0200 Subject: [PATCH 03/10] [miio] improvements based on feedback * feedback improvements * token obfuscation for log sharing * basic handler cleanup and improvements Signed-off-by: Marcel Verpaalen --- .../org.openhab.binding.miio/README.base.md | 1 + bundles/org.openhab.binding.miio/README.md | 1 + .../binding/miio/internal/MiIoCrypto.java | 10 +- .../miio/internal/MiIoCryptoException.java | 16 +-- .../miio/internal/MiIoHandlerFactory.java | 16 ++- .../miio/internal/cloud/CloudConnector.java | 80 +++++++-------- .../miio/internal/cloud/CloudCrypto.java | 4 +- .../miio/internal/cloud/CloudUtil.java | 4 +- .../miio/internal/cloud/MiCloudConnector.java | 69 ++++--------- .../miio/internal/cloud/MiCloudException.java | 15 +-- .../internal/discovery/MiIoDiscovery.java | 15 ++- .../internal/handler/MiIoBasicHandler.java | 35 +------ .../internal/handler/MiIoVacuumHandler.java | 45 +++++---- .../miio/internal/robot/RRMapDraw.java | 94 +++++++++++------- .../miio/internal/robot/RRMapFileParser.java | 66 ++++++------ .../transport/MiIoAsyncCommunication.java | 6 +- .../src/main/resources/images/charger.png | Bin 0 -> 1403 bytes .../src/main/resources/images/ohlogo.png | Bin 0 -> 2792 bytes .../src/main/resources/images/robo.png | Bin 0 -> 10188 bytes .../binding/miio/internal/RoboMapViewer.java | 10 +- 20 files changed, 236 insertions(+), 251 deletions(-) create mode 100644 bundles/org.openhab.binding.miio/src/main/resources/images/charger.png create mode 100644 bundles/org.openhab.binding.miio/src/main/resources/images/ohlogo.png create mode 100644 bundles/org.openhab.binding.miio/src/main/resources/images/robo.png diff --git a/bundles/org.openhab.binding.miio/README.base.md b/bundles/org.openhab.binding.miio/README.base.md index 41438904fc6c5..d07fc7825186f 100644 --- a/bundles/org.openhab.binding.miio/README.base.md +++ b/bundles/org.openhab.binding.miio/README.base.md @@ -156,6 +156,7 @@ Switch lastCompleted "Last Cleaning Completed" (gVacLast) {channel="miio:vac Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"} ``` + Note: cleaning map is only available with cloud access. !!!itemFileExamples diff --git a/bundles/org.openhab.binding.miio/README.md b/bundles/org.openhab.binding.miio/README.md index 7f88db7855a95..0d63329c5f585 100644 --- a/bundles/org.openhab.binding.miio/README.md +++ b/bundles/org.openhab.binding.miio/README.md @@ -1433,6 +1433,7 @@ Switch lastCompleted "Last Cleaning Completed" (gVacLast) {channel="miio:vac Image map "Cleaning Map" (gVacLast) {channel="miio:vacuum:034F0E45:cleaning#map"} ``` + Note: cleaning map is only available with cloud access. ### Mi Air Monitor v1 (zhimi.airmonitor.v1) item file lines diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCrypto.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCrypto.java index 1f5a0c80e25ed..428b4b1932e41 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCrypto.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCrypto.java @@ -40,7 +40,7 @@ public static byte[] md5(byte[] source) throws MiIoCryptoException { MessageDigest m = MessageDigest.getInstance("MD5"); return m.digest(source); } catch (NoSuchAlgorithmException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(),e); } } @@ -52,7 +52,7 @@ public static byte[] iv(byte[] token) throws MiIoCryptoException { System.arraycopy(token, 0, ivbuf, 16, 16); return m.digest(ivbuf); } catch (NoSuchAlgorithmException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(),e); } } @@ -66,7 +66,7 @@ public static byte[] encrypt(byte[] cipherText, byte[] key, byte[] iv) throws Mi return encrypted; } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(),e); } } @@ -84,7 +84,7 @@ public static byte[] decrypt(byte[] cipherText, byte[] key, byte[] iv) throws Mi return (crypted); } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(),e); } } @@ -106,7 +106,7 @@ public static String decryptToken(byte[] cipherText) throws MiIoCryptoException } } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | IllegalBlockSizeException | BadPaddingException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(),e); } } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCryptoException.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCryptoException.java index fabd57217a537..adf1663c56dd8 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCryptoException.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoCryptoException.java @@ -22,16 +22,20 @@ @NonNullByDefault public class MiIoCryptoException extends Exception { + /** + * required variable to avoid IncorrectMultilineIndexException warning + */ + private static final long serialVersionUID = -1280858607995252320L; + public MiIoCryptoException() { super(); } - public MiIoCryptoException(String arg0) { - super(arg0); + public MiIoCryptoException(String message) { + super(message); } - /** - * required variable to avoid IncorrectMultilineIndexException warning - */ - private static final long serialVersionUID = -1280858607995252320L; + public MiIoCryptoException(String message, Exception e) { + super(message, e); + } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java index e2ad3c5bb5202..13daa15ec2bff 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java @@ -14,16 +14,15 @@ import static org.openhab.binding.miio.internal.MiIoBindingConstants.*; +import java.util.Dictionary; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import java.util.Dictionary; -import org.eclipse.jetty.client.HttpClient; import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingTypeUID; import org.eclipse.smarthome.core.thing.binding.BaseThingHandlerFactory; import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.eclipse.smarthome.core.thing.binding.ThingHandlerFactory; -import org.eclipse.smarthome.io.net.http.HttpClientFactory; import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService; import org.openhab.binding.miio.internal.cloud.CloudConnector; import org.openhab.binding.miio.internal.handler.MiIoBasicHandler; @@ -46,13 +45,13 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory { private MiIoDatabaseWatchService miIoDatabaseWatchService; - private @NonNullByDefault({}) HttpClient httpClient; + private CloudConnector cloudConnector; @Activate public MiIoHandlerFactory(@Reference MiIoDatabaseWatchService miIoDatabaseWatchService, - @Reference HttpClientFactory httpClientFactory) { + @Reference CloudConnector cloudConnector) { this.miIoDatabaseWatchService = miIoDatabaseWatchService; - this.httpClient = httpClientFactory.createHttpClient(BINDING_ID); + this.cloudConnector = cloudConnector; } @Override @@ -65,8 +64,7 @@ protected void activate(ComponentContext componentContext) { String password = (String) properties.get("password"); @Nullable String country = (String) properties.get("country"); - CloudConnector.getInstance().setHttpClient(httpClient); - CloudConnector.getInstance().setCredentials(username, password, country); + cloudConnector.setCredentials(username, password, country); } @Override @@ -84,7 +82,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { return new MiIoBasicHandler(thing, miIoDatabaseWatchService); } if (thingTypeUID.equals(THING_TYPE_VACUUM)) { - return new MiIoVacuumHandler(thing, miIoDatabaseWatchService); + return new MiIoVacuumHandler(thing, miIoDatabaseWatchService, cloudConnector); } return new MiIoUnsupportedHandler(thing, miIoDatabaseWatchService); } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java index 4dc82b2be55be..782493f3c414b 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.miio.internal.cloud; +import static org.openhab.binding.miio.internal.MiIoBindingConstants.BINDING_ID; + import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -20,8 +22,13 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.eclipse.smarthome.core.cache.ExpiringCache; +import org.eclipse.smarthome.core.library.types.RawType; +import org.eclipse.smarthome.io.net.http.HttpClientFactory; import org.eclipse.smarthome.io.net.http.HttpUtil; -import org.jetbrains.annotations.NotNull; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,6 +42,7 @@ * * @author Marcel Verpaalen - Initial contribution */ +@Component(service = CloudConnector.class) @NonNullByDefault public class CloudConnector { @@ -48,16 +56,12 @@ public class CloudConnector { private String username = ""; private String password = ""; private String country = "ru,us,tw,sg,cn,de"; - private List deviceList = new ArrayList(); - private final JsonParser parser = new JsonParser(); private boolean connected; - - private @Nullable HttpClient httpClient; - + private final HttpClient httpClient; private @Nullable MiCloudConnector cloudConnector; - public static final CloudConnector CC_INSTANCE = new CloudConnector(); private final Logger logger = LoggerFactory.getLogger(CloudConnector.class); + private final JsonParser parser = new JsonParser(); private ExpiringCache logonCache = new ExpiringCache(CACHE_EXPIRY, () -> { return logon(); @@ -90,11 +94,18 @@ public class CloudConnector { return "done";// deviceList; }); - private CloudConnector() { + @Activate + public CloudConnector(@Reference HttpClientFactory httpClientFactory) { + this.httpClient = httpClientFactory.createHttpClient(BINDING_ID); } - public static CloudConnector getInstance() { - return CC_INSTANCE; + @Deactivate + public void dispose() { + final MiCloudConnector cl = cloudConnector; + if (cl != null) { + cl.stopClient(); + } + cloudConnector = null; } public boolean isConnected() { @@ -110,7 +121,7 @@ public boolean isConnected() { return false; } - public byte[] getMap(String mapId, String country) throws MiCloudException { + public @Nullable RawType getMap(String mapId, String country) throws MiCloudException { logger.info("Getting vacuum map {} from Xiaomi cloud server: {}", mapId, country); String mapCountry; String mapUrl = ""; @@ -119,14 +130,11 @@ public byte[] getMap(String mapId, String country) throws MiCloudException { throw new MiCloudException("Cannot execute request. Cloudservice not available"); } if (country.isEmpty()) { - // TODO: pick the right server in a more intelligent way logger.debug("Server not defined in thing. Trying servers: {}", this.country); for (String mapCountryServer : this.country.split(",")) { - ; mapCountry = mapCountryServer.trim().toLowerCase(); mapUrl = cl.getMapUrl(mapId, mapCountry); logger.debug("Map download from server {} returned {}", mapCountry, mapUrl); - if (!mapUrl.isEmpty()) { break; } @@ -135,8 +143,14 @@ public byte[] getMap(String mapId, String country) throws MiCloudException { mapCountry = country.trim().toLowerCase(); mapUrl = cl.getMapUrl(mapId, mapCountry); } - byte[] mapData = HttpUtil.downloadData(mapUrl, null, false, -1).getBytes(); - return mapData; + @Nullable + RawType mapData = HttpUtil.downloadData(mapUrl, null, false, -1); + if (mapData != null) { + return mapData; + } else { + logger.debug("Could not download '{}'", mapUrl); + return null; + } } public void setCredentials(@Nullable String username, @Nullable String password, @Nullable String country) { @@ -150,36 +164,26 @@ public void setCredentials(@Nullable String username, @Nullable String password, } } - public void setHttpClient(@NotNull HttpClient httpClient) { - this.httpClient = httpClient; - } - private boolean logon() { if (username.isEmpty() || password.isEmpty()) { logger.info("No Xiaomi cloud credentials. Cloud connectivity diabled"); - logger.debug("Username: {} pass: {}, country:{}", username, password.replaceAll(".", "*"), country); + logger.debug("Logon details: username: '{}', pass: '{}', country: '{}'", username, + password.replaceAll(".", "*"), country); return connected; } - final HttpClient httpClient = this.httpClient; - if (httpClient != null) { - try { - final MiCloudConnector cl = new MiCloudConnector(username, password, httpClient); - this.cloudConnector = cl; - connected = cl.login(); - if (connected) { - getDevicesList(); - } else { - deviceListState = FAILED; - } - } catch (MiCloudException e) { - connected = false; + try { + final MiCloudConnector cl = new MiCloudConnector(username, password, httpClient); + this.cloudConnector = cl; + connected = cl.login(); + if (connected) { + getDevicesList(); + } else { deviceListState = FAILED; - logger.debug("Xiaomi cloud login failed: {}", e.getMessage()); } - } else { - logger.info("HTTP client not set. Cloud connectivity diabled"); + } catch (MiCloudException e) { connected = false; deviceListState = FAILED; + logger.debug("Xiaomi cloud login failed: {}", e.getMessage()); } return connected; } @@ -187,7 +191,6 @@ private boolean logon() { public List getDevicesList() { refreshDeviceList.getValue(); return deviceList; - } public JsonObject getDeviceInfo(String id) { @@ -212,7 +215,6 @@ public JsonObject getDeviceInfo(String id) { } } } - JsonObject returnvalue = new JsonObject(); switch (devicedata.size()) { case 0: diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java index db1e3563aa0b4..769747b45997d 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudCrypto.java @@ -44,7 +44,7 @@ public static String sha256Hash(byte[] inBytes) throws MiIoCryptoException { MessageDigest md = MessageDigest.getInstance("SHA-256"); return Base64.getEncoder().encodeToString(md.digest(inBytes)); } catch (NoSuchAlgorithmException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(), e); } } @@ -63,7 +63,7 @@ public static String hMacSha256Encode(byte[] key, byte[] cipherText) throws MiIo sha256Hmac.init(secretKey); return Base64.getEncoder().encodeToString(sha256Hmac.doFinal(cipherText)); } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new MiIoCryptoException(e.getMessage()); + throw new MiIoCryptoException(e.getMessage(), e); } } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java index ee06e1be235a6..048f9a8a56b96 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java @@ -55,7 +55,7 @@ public static String getElementString(JsonElement jsonElement, String element, L String value = ""; try { value = jsonElement.getAsJsonObject().get(element).getAsString(); - } catch (Exception e) { + } catch (IllegalStateException | ClassCastException e) { logger.debug("Json Element {} expected but missing", element); } return value; @@ -81,7 +81,7 @@ public static void printDevices(String response, String country, Logger logger) } else { logger.debug("Response is not a json object: '{}'", response); } - } catch (JsonSyntaxException | IllegalStateException e) { + } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) { logger.info("Error while printing devices: {}", e.getMessage()); } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java index 54d334f2be97f..b628f51bf9d66 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -20,6 +20,7 @@ import java.net.URL; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Random; import java.util.concurrent.ExecutionException; @@ -61,18 +62,16 @@ public class MiCloudConnector { .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 + " APP/xiaomi.smarthome APPV/62830"; + private static Locale locale = Locale.getDefault(); private final JsonParser parser = new JsonParser(); + private final String clientId; - // String private String username; private String password; - - private final String clientId; private String userId = ""; private String serviceToken = ""; private String ssecurity = ""; - int loginFailedCounter = 0; - + private int loginFailedCounter = 0; private HttpClient httpClient; private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class); @@ -99,7 +98,7 @@ void startClient() throws MiCloudException { addCookie(cookieStore, "deviceId", this.clientId, "mi.com"); addCookie(cookieStore, "deviceId", this.clientId, "xiaomi.com"); } catch (Exception e) { - throw new MiCloudException("No http client cannot be started: " + e.getMessage()); + throw new MiCloudException("No http client cannot be started: " + e.getMessage(), e); } } } @@ -108,7 +107,7 @@ public void stopClient() { try { this.httpClient.stop(); } catch (Exception e) { - logger.debug("Error stopping httpclient :{}", e.getMessage()); + logger.debug("Error stopping httpclient :{}", e.getMessage(), e); } } @@ -168,28 +167,9 @@ public String getDeviceStatus(String device, String country) throws MiCloudExcep String url = getApiUrl(country) + "/home/device_list"; Map map = new HashMap(); map.put("data", "{\"dids\":[\"" + device + "\"]}"); - logger.debug("response: {}", request(url, map)); - return ""; - } - - public String getLatest(String model, String country) { - String url = getApiUrl(country) + "/home/latest_version"; - Map map = new HashMap(); - map.put("data", "{\"model\":\"" + model + "\"}"); - String resp; - try { - resp = request(url, map); - logger.debug("Response: {}", resp); - // CloudUtil.printDevices(resp, logger); - if (resp.length() > 2) { - // CloudUtil.saveFile(resp, country, logger); - return resp; - } - } catch (MiCloudException e) { - logger.debug("{}", e.getMessage()); - return ""; - } - return ""; + final String response = request(url, map); + logger.debug("response: {}", response); + return response; } public String getDevices(String country) { @@ -222,11 +202,7 @@ public String request(String url, Map params) throws MiCloudExce if (this.serviceToken.isEmpty() || this.userId.isEmpty()) { throw new MiCloudException("Cannot execute request. service token or userId missing"); } - try { - startClient(); - } catch (Exception e) { - throw new MiCloudException("Cannot Execute request. service token or userId missing" + e.getMessage()); - } + startClient(); logger.debug("Send request: {} to {}", params.get("data"), url); Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); request.agent(USERAGENT); @@ -235,7 +211,7 @@ public String request(String url, Map params) throws MiCloudExce request.cookie(new HttpCookie("userId", this.userId)); request.cookie(new HttpCookie("yetAnotherServiceToken", this.serviceToken)); request.cookie(new HttpCookie("serviceToken", this.serviceToken)); - request.cookie(new HttpCookie("locale", "uk_GB")); + request.cookie(new HttpCookie("locale", locale.toString())); request.cookie(new HttpCookie("timezone", "GMT%2B01%3A00")); request.cookie(new HttpCookie("is_daylight", "1")); request.cookie(new HttpCookie("dst_offset", "3600000")); @@ -265,18 +241,10 @@ public String request(String url, Map params) throws MiCloudExce } catch (HttpResponseException e) { serviceToken = ""; logger.debug("Error while executing request to {} :{}", url, e.getMessage()); - } catch (InterruptedException e) { - logger.debug("Error while executing request to {} :{}", url, e.getMessage()); - } catch (TimeoutException e) { - logger.debug("Error while executing request to {} :{}", url, e.getMessage()); - } catch (ExecutionException e) { - logger.debug("Error while executing request to {} :{}", url, e.getMessage()); - } catch (IOException e) { + } catch (InterruptedException | TimeoutException | ExecutionException | IOException e) { logger.debug("Error while executing request to {} :{}", url, e.getMessage()); } catch (MiIoCryptoException e) { - logger.debug("Error while executing request to {} :{}", url, e.getMessage(), e); - } catch (Exception e) { - logger.debug("Error while executing request to {} :{}", url, e.getMessage(), e); + logger.debug("Error while decrypting response of request to {} :{}", url, e.getMessage(), e); } return ""; } @@ -288,7 +256,6 @@ private void addCookie(CookieStore cookieStore, String name, String value, Strin cookieStore.add(URI.create("https://" + domain), cookie); } - // TODO: better way instead of blocking ? public synchronized boolean login() { if (!checkCredentials()) { return false; @@ -331,13 +298,12 @@ protected boolean loginRequest() throws MiCloudException { throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason()); } } catch (InterruptedException | TimeoutException | ExecutionException e) { - throw new MiCloudException("Cannot logon to Xiaomi cloud: " + e.getMessage()); + 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()); + throw new MiCloudException("Error decrypting. Cannot logon to Xiaomi cloud: " + e.getMessage(), e); } catch (MalformedURLException e) { - throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage()); + throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage(), e); } - } private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException { @@ -360,7 +326,7 @@ private String loginStep1() throws InterruptedException, TimeoutException, Execu logger.trace("Xiaomi Login step 1 sign = {}", sign); return sign; } catch (JsonSyntaxException e) { - throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage()); + throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e); } } @@ -456,7 +422,6 @@ private String extractServiceToken(URI uri) { serviceToken = cookie.getValue(); logger.debug("Xiaomi cloud logon succesfull."); logger.trace("Xiaomi cloud servicetoken: {}", serviceToken); - } } return serviceToken; diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java index 839aaa8e9c1ad..8436c1b51456d 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java @@ -21,17 +21,20 @@ */ @NonNullByDefault public class MiCloudException extends Exception { + /** + * required variable to avoid IncorrectMultilineIndexException warning + */ + private static final long serialVersionUID = -1280858607995252321L; public MiCloudException() { super(); } - public MiCloudException(String arg0) { - super(arg0); + public MiCloudException(String message) { + super(message); } - /** - * required variable to avoid IncorrectMultilineIndexException warning - */ - private static final long serialVersionUID = -1280858607995252321L; + public MiCloudException(String message, Exception e) { + super(message, e); + } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java index a6798c255f2bb..00062ba7487a1 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java @@ -36,7 +36,9 @@ import org.openhab.binding.miio.internal.Message; import org.openhab.binding.miio.internal.Utils; import org.openhab.binding.miio.internal.cloud.CloudConnector; +import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,12 +63,15 @@ public class MiIoDiscovery extends AbstractDiscoveryService { private @Nullable ScheduledFuture miIoDiscoveryJob; protected @Nullable DatagramSocket clientSocket; private @Nullable Thread socketReceiveThread; - Set responseIps = new HashSet(); + private Set responseIps = new HashSet(); private final Logger logger = LoggerFactory.getLogger(MiIoDiscovery.class); + private final CloudConnector cloudConnector; - public MiIoDiscovery() throws IllegalArgumentException { + @Activate + public MiIoDiscovery(@Reference CloudConnector cloudConnector) throws IllegalArgumentException { super(DISCOVERY_TIME); + this.cloudConnector = cloudConnector; } @Override @@ -137,9 +142,9 @@ private void discovered(String ip, byte[] response) { String label = "Xiaomi Mi Device " + id + " (" + Long.parseUnsignedLong(id, 16) + ")"; String country = ""; boolean isOnline = false; - if (CloudConnector.getInstance().isConnected()) { - CloudConnector.getInstance().getDevicesList(); - JsonObject cloudInfo = CloudConnector.getInstance().getDeviceInfo(id); + if (cloudConnector.isConnected()) { + cloudConnector.getDevicesList(); + JsonObject cloudInfo = cloudConnector.getDeviceInfo(id); logger.debug("Cloud Response: {}", cloudInfo.toString()); if (cloudInfo.has("token")) { token = cloudInfo.get("token").getAsString(); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java index 7e1a819bc67db..49c2c171df617 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java @@ -194,22 +194,12 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override protected synchronized void updateData() { - final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID()); - final MiIoAsyncCommunication miioCom = this.miioCom; + final MiIoAsyncCommunication miioCom = getConnection(); try { - if (!hasConnection() || skipUpdate()) { - return; - } - if (miioCom == null || !initializeData()) { + if (!hasConnection() || skipUpdate() || miioCom == null) { return; } - try { - miioCom.startReceiver(); - miioCom.sendPing(configuration.host); - } catch (Exception e) { - // ignore - } checkChannelStructure(); if (!isIdentified) { miioCom.queueCommand(MiIoCommand.MIIO_INFO); @@ -252,21 +242,6 @@ private void sendRefreshProperties(MiIoCommand command, JsonArray getPropString) } } - @Override - protected boolean initializeData() { - final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); - final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token, - Utils.hexStringToByteArray(configuration.deviceId), lastId, configuration.timeout); - miioCom.registerListener(this); - try { - miioCom.sendPing(configuration.host); - } catch (IOException e) { - logger.debug("ping {} failed", configuration.host); - } - this.miioCom = miioCom; - return true; - } - /** * Checks if the channel structure has been build already based on the model data. If not build it. */ @@ -332,10 +307,8 @@ private boolean buildChannelStructure(String deviceName) { updateThing(thingBuilder.build()); } return true; - } catch (JsonIOException e) { - logger.warn("Error reading database Json", e); - } catch (JsonSyntaxException e) { - logger.warn("Error reading database Json", e); + } catch (JsonIOException | JsonSyntaxException e) { + logger.warn("Error parsing database Json", e); } catch (IOException e) { logger.warn("Error reading database file", e); } catch (Exception e) { diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java index cb81486f7cba4..190a117910760 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java @@ -28,6 +28,7 @@ import javax.imageio.ImageIO; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.eclipse.smarthome.config.core.ConfigConstants; import org.eclipse.smarthome.core.cache.ExpiringCache; import org.eclipse.smarthome.core.library.types.DateTimeType; @@ -81,9 +82,12 @@ public class MiIoVacuumHandler extends MiIoAbstractHandler { private ExpiringCache map; private String lastHistoryId = ""; private String lastMap = ""; + private CloudConnector cloudConnector; - public MiIoVacuumHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService) { + public MiIoVacuumHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService, + CloudConnector cloudConnector) { super(thing, miIoDatabaseWatchService); + this.cloudConnector = cloudConnector; mapChannelUid = new ChannelUID(thing.getUID(), CHANNEL_VACUUM_MAP); initializeData(); status = new ExpiringCache(CACHE_EXPIRY, () -> { @@ -456,26 +460,29 @@ public void onMessageReceived(MiIoSendCommand response) { private State getMap(String map) { final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); - if (CloudConnector.getInstance().isConnected()) { + if (cloudConnector.isConnected()) { try { - byte[] mapData = CloudConnector.getInstance().getMap(map, + final @Nullable RawType mapDl = cloudConnector.getMap(map, (configuration.cloudServer != null) ? configuration.cloudServer : ""); - RRMapDraw rrMap = RRMapDraw.loadImage(new ByteArrayInputStream(mapData)); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - if (logger.isDebugEnabled()) { - String fn = ConfigConstants.getUserDataFolder() + File.separator + BINDING_ID + File.separator - + map; - CloudUtil.writeBytesToFileNio(mapData, - fn + (new SimpleDateFormat("yyyyMMdd-HHss")).format(new Date()) + ".rrmap"); - logger.debug("Mapdata saved to {}", fn + ".rrmap"); - } - ImageIO.write(rrMap.getImage(MAP_SCALE), "jpg", baos); - byte[] byteArray = baos.toByteArray(); - if (byteArray != null && byteArray.length > 0) { - return new RawType(byteArray, "image/jpeg"); - } else { - logger.debug("Mapdata empty removing image"); - return UnDefType.UNDEF; + if (mapDl != null) { + byte[] mapData = mapDl.getBytes(); + RRMapDraw rrMap = RRMapDraw.loadImage(new ByteArrayInputStream(mapData)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (logger.isDebugEnabled()) { + String fn = ConfigConstants.getUserDataFolder() + File.separator + BINDING_ID + File.separator + + map; + CloudUtil.writeBytesToFileNio(mapData, + fn + (new SimpleDateFormat("yyyyMMdd-HHss")).format(new Date()) + ".rrmap"); + logger.debug("Mapdata saved to {}", fn + ".rrmap"); + } + ImageIO.write(rrMap.getImage(MAP_SCALE), "jpg", baos); + byte[] byteArray = baos.toByteArray(); + if (byteArray != null && byteArray.length > 0) { + return new RawType(byteArray, "image/jpeg"); + } else { + logger.debug("Mapdata empty removing image"); + return UnDefType.UNDEF; + } } } catch (MiCloudException e) { logger.debug("Error getting data from Xiaomi cloud. Mapdata could not be updated: {}", e.getMessage()); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java index 33b3d3595f331..98b87292f23df 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java @@ -14,7 +14,6 @@ import java.awt.BasicStroke; import java.awt.Color; -import java.awt.Dimension; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics2D; @@ -27,16 +26,21 @@ import java.awt.geom.Rectangle2D; import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; -import java.util.Base64; +import java.net.MalformedURLException; +import java.net.URL; import javax.imageio.ImageIO; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Draws the vacuum map file to an image @@ -74,11 +78,12 @@ public class RRMapDraw { private static final Color ROOM16 = new Color(165, 105, 189); private static final Color[] ROOM_COLORS = { ROOM1, ROOM2, ROOM3, ROOM4, ROOM5, ROOM6, ROOM7, ROOM8, ROOM9, ROOM10, ROOM11, ROOM12, ROOM13, ROOM14, ROOM15, ROOM16 }; + private final @Nullable Bundle bundle = FrameworkUtil.getBundle(getClass()); private boolean multicolor = false; - - Dimension size = new Dimension(); private RRMapFileParser rmfp; + private final Logger logger = LoggerFactory.getLogger(RRMapDraw.class); + public RRMapDraw(RRMapFileParser rmfp) { this.rmfp = rmfp; } @@ -191,7 +196,7 @@ private void drawPath(Graphics2D g2d, float scale) { } float prvX = 0; float prvY = 0; - for (Float[] point : rmfp.getPaths().get(pathType)) { + for (float[] point : rmfp.getPaths().get(pathType)) { float x = point[0] * scale; float y = point[1] * scale; if (prvX > 1) { @@ -204,7 +209,7 @@ private void drawPath(Graphics2D g2d, float scale) { } private void drawZones(Graphics2D g2d, float scale) { - for (Float[] point : rmfp.getZones()) { + for (float[] point : rmfp.getZones()) { float x = point[0] * scale; float y = point[1] * scale; float x1 = point[2] * scale; @@ -220,7 +225,7 @@ private void drawZones(Graphics2D g2d, float scale) { private void drawNoGo(Graphics2D g2d, float scale) { for (Integer area : rmfp.getAreas().keySet()) { - for (Float[] point : rmfp.getAreas().get(area)) { + for (float[] point : rmfp.getAreas().get(area)) { float x = point[0] * scale; float y = point[1] * scale; float x1 = point[2] * scale; @@ -246,7 +251,7 @@ private void drawNoGo(Graphics2D g2d, float scale) { private void drawWalls(Graphics2D g2d, float scale) { Stroke stroke = new BasicStroke(3 * scale); g2d.setStroke(stroke); - for (Float[] point : rmfp.getWalls()) { + for (float[] point : rmfp.getWalls()) { float x = point[0] * scale; float y = point[1] * scale; float x1 = point[2] * scale; @@ -262,16 +267,12 @@ private void drawRobo(Graphics2D g2d, float scale) { g2d.setStroke(stroke); g2d.setColor(COLOR_CHARGER_HALO); drawCircle(g2d, rmfp.getChargerX() * scale, rmfp.getChargerY() * scale, radius); - drawCenteredImg(g2d, scale / 8, - "iVBORw0KGgoAAAANSUhEUgAAACoAAAAqCAMAAADyHTlpAAADAFBMVEVHcExF5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o5F5o7////w/fa69tVm6qL1/vlt66br/PNf6p7+//7o/PGE7rR37ay39dOL77nM+OCh8sbB99n2/vlH5o9e6Z2c8sPk++/j++5s66Vj6qBg6p6i88dn66Nq66VY6Zq99td67a6S8L30/vjb+ul57a2C7rNW6Jiv9M5Q55UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmUTLUAAAALnRSTlMAJPkvN+wI+4DP1eoxP47t7v4GrpmcB6+Ptj7w+vMJ9Lm11DIlMI0m0DUuM/G06wfbVgAAAfxJREFUeNqNletzmzAMwGVhXg0NTbLkbtl12fvW/f//ya7Xy7h82Nruwa5p8yhhgPGAFrCN6VWfbOmHZEtCNkCSMUX/ZZ6S4SvkrneQbETceE66ELY/aLzXo8bru1M5Blw6IWvt9cIa8LmvkOAPbqcJU7wS5yPoJYi55PUs+tBDwmSNuYBSew69MqVRxWIV3R7BEzKySe3VsjTRrUWSNtneInu41vCNhuTZ0X27jb9VBzD+aUmYCPvvXyqvb4+1pBuJGv618OrddcjTgoSppLryCtRVqwn0Oqtz037tAp2ZHTKvypMqZ5ohnejJ0UEpGsW1ngR2oxjW6OtJuZGrLsOdtJ/XJGQqukPp9M5NTYJpVNKmLKVMJHnSrB+z3RaXYQ9Zpyhv12i062GXJHH7a1GhAuZjCCvWkIVV6JVrVsmotpOxQMIxbjrNUh+D4C9RvcFxB61rgHJqTczUAsKRlsxdDNVrfwp1JPwJEYa6+B0SZkUDr7ayrswYnank9rw0mEtJWUCU/FbI5WXlI7BF5eefBdkpm80ewp0EgjKONWRyUp6qHLlk9b7tbdIlVxFvxhu3nOa/4EwlN7e58F9QctY73S541qSmvPYi6CODd5k84It5e/VCAy7zKFTfArYHMuiQf3c79rzHyF/1vVvF0E3TwcHyYJ+496Ypj5P/uAmtfUpJqE0AAAAASUVORK5CYII=", - rmfp.getChargerX() * scale, rmfp.getChargerY() * scale); + drawCenteredImg(g2d, scale / 8, "charger.png", rmfp.getChargerX() * scale, rmfp.getChargerY() * scale); radius = 3 * scale; g2d.setColor(COLOR_ROBO); drawCircle(g2d, rmfp.getRoboX() * scale, rmfp.getRoboY() * scale, radius); if (scale > 1.5) { - drawCenteredImg(g2d, scale / 15, - "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", - rmfp.getRoboX() * scale, rmfp.getRoboY() * scale); + drawCenteredImg(g2d, scale / 15, "robo.png", rmfp.getRoboX() * scale, rmfp.getRoboY() * scale); } } @@ -279,17 +280,22 @@ private void drawCircle(Graphics2D g2d, float x, float y, float radius) { g2d.draw(new Ellipse2D.Double(x - radius, y - radius, 2.0 * radius, 2.0 * radius)); } - private void drawCenteredImg(Graphics2D g2d, float scale, String imgData, float x, float y) { + private void drawCenteredImg(Graphics2D g2d, float scale, String imgFile, float x, float y) { + URL image = getImageUrl(imgFile); try { - BufferedImage addImg = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(imgData))); - int xpos = Math.round(x - (addImg.getWidth() / 2 * scale)); - int ypos = Math.round(y - (addImg.getHeight() / 2 * scale)); - AffineTransform at = new AffineTransform(); - at.scale(scale, scale); - AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); - g2d.drawImage(addImg, scaleOp, xpos, ypos); + if (image != null) { + BufferedImage addImg = ImageIO.read(image); + int xpos = Math.round(x - (addImg.getWidth() / 2 * scale)); + int ypos = Math.round(y - (addImg.getHeight() / 2 * scale)); + AffineTransform at = new AffineTransform(); + at.scale(scale, scale); + AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); + g2d.drawImage(addImg, scaleOp, xpos, ypos); + } else { + logger.debug("Error loading image {}: File not be found.", imgFile); + } } catch (IOException e) { - // ignore + logger.debug("Error loading image {}: {}", image, e.getMessage()); } } @@ -302,24 +308,27 @@ private void drawGoTo(Graphics2D g2d, float scale) { int x3[] = { (int) x, (int) (x - 2 * scale), (int) (x + 2 * scale) }; int y3[] = { (int) y, (int) (y - 5 * scale), (int) (y - 5 * scale) }; g2d.fill(new Polygon(x3, y3, 3)); - } } private void drawOpenHabRocks(Graphics2D g2d, int width, int height, float scale) { - // easter egg gift : + // easter egg gift int offset = 5; int textPos = 55; + URL image = getImageUrl("ohlogo.png"); try { - BufferedImage ohLogo = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode( - "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"))); - textPos = (int) (ohLogo.getWidth() * scale / 2 + offset); - AffineTransform at = new AffineTransform(); - at.scale(scale / 2, scale / 2); - AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); - g2d.drawImage(ohLogo, scaleOp, offset, height - (int) (ohLogo.getHeight() * scale / 2) - offset); + if (image != null) { + BufferedImage ohLogo = ImageIO.read(image); + textPos = (int) (ohLogo.getWidth() * scale / 2 + offset); + AffineTransform at = new AffineTransform(); + at.scale(scale / 2, scale / 2); + AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); + g2d.drawImage(ohLogo, scaleOp, offset, height - (int) (ohLogo.getHeight() * scale / 2) - offset); + } else { + logger.debug("Error loading image ohlogo.png: File not be found."); + } } catch (IOException e) { - // no joy + logger.debug("Error loading image ohlogo.png:: {}", e.getMessage()); } Font font = new Font("TimesRoman", Font.BOLD, 14); g2d.setFont(font); @@ -327,7 +336,7 @@ private void drawOpenHabRocks(Graphics2D g2d, int width, int height, float scale FontMetrics fontMetrics = g2d.getFontMetrics(); int stringWidth = fontMetrics.stringWidth(message); if ((stringWidth + textPos) > rmfp.getImgWidth() * scale) { - font = new Font("TimesRoman", Font.BOLD, + font = new Font("Helvetica ", Font.BOLD, (int) Math.floor(14 * (rmfp.getImgWidth() * scale - textPos - offset) / stringWidth)); g2d.setFont(font); } @@ -336,6 +345,20 @@ private void drawOpenHabRocks(Graphics2D g2d, int width, int height, float scale g2d.drawString(message, textPos, height - offset - stringHeight / 2); } + private @Nullable URL getImageUrl(String image) { + if (bundle != null) { + return bundle.getEntry("images/" + image); + } + try { + File fn = new File("src" + File.separator + "main" + File.separator + "resources" + File.separator + + "images" + File.separator + image); + return fn.toURI().toURL(); + } catch (MalformedURLException | SecurityException e) { + logger.debug("Could create URL for {}: {}", image, e.getMessage()); + return null; + } + } + public BufferedImage getImage(float scale) { int width = (int) Math.floor(rmfp.getImgWidth() * scale); int height = (int) Math.floor(rmfp.getImgHeight() * scale); @@ -354,7 +377,6 @@ public BufferedImage getImage(float scale) { g2d = bi.createGraphics(); drawOpenHabRocks(g2d, width, height, scale); return bi; - } public boolean writePic(String filename, String formatName, float scale) throws IOException { diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java index fbd54a5db01d7..3a3d6d020a388 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java @@ -60,10 +60,10 @@ public class RRMapFileParser { private static final float MM = 50.0f; private byte[] image = new byte[] { 0 }; - private int majorVersion; - private int minorVersion; - private int mapIndex; - private int mapSequence; + private final int majorVersion; + private final int minorVersion; + private final int mapIndex; + private final int mapSequence; private boolean isValid; private int imgHeight; @@ -80,12 +80,12 @@ public class RRMapFileParser { private int roboA; private float gotoX = 0; private float gotoY = 0; - private Map> paths = new HashMap<>(); + private Map> paths = new HashMap<>(); private Map> pathsDetails = new HashMap<>(); - private Map> areas = new HashMap<>(); - private ArrayList walls = new ArrayList(); - private ArrayList zones = new ArrayList(); - private ArrayList obstacles = new ArrayList(); + private Map> areas = new HashMap<>(); + private ArrayList walls = new ArrayList<>(); + private ArrayList zones = new ArrayList<>(); + private ArrayList obstacles = new ArrayList<>(); private byte[] blocks = new byte[0]; private final Logger logger = LoggerFactory.getLogger(RRMapFileParser.class); @@ -137,16 +137,16 @@ public RRMapFileParser(byte[] raw) { case PATH: case GOTO_PATH: case GOTO_PREDICTED_PATH: - ArrayList path = new ArrayList(); + ArrayList path = new ArrayList(); Map detail = new HashMap(); int pairs = getUInt32LE(header, 0x04) / 4; detail.put(PATH_POINT_LENGTH, getUInt32LE(header, 0x08)); detail.put(PATH_POINT_SIZE, getUInt32LE(header, 0x0C)); detail.put(PATH_ANGLE, getUInt32LE(header, 0x10)); for (int pathpair = 0; pathpair < pairs; pathpair++) { - Float x = offset - (getUInt16(getBytes(raw, blockDataStart + pathpair * 4, 2))) / MM; - Float y = getUInt16(getBytes(raw, blockDataStart + pathpair * 4 + 2, 2)) / MM - top; - path.add(new Float[] { x, y }); + float x = offset - (getUInt16(getBytes(raw, blockDataStart + pathpair * 4, 2))) / MM; + float y = getUInt16(getBytes(raw, blockDataStart + pathpair * 4 + 2, 2)) / MM - top; + path.add(new float[] { x, y }); } paths.put(blocktype, path); pathsDetails.put(blocktype, detail); @@ -158,7 +158,7 @@ public RRMapFileParser(byte[] raw) { Float y0 = getUInt16(raw, blockDataStart + zonePair * 8 + 2) / MM - top; Float x1 = offset - (getUInt16(raw, blockDataStart + zonePair * 8 + 4)) / MM; Float y1 = getUInt16(raw, blockDataStart + zonePair * 8 + 6) / MM - top; - zones.add(new Float[] { x0, y0, x1, y1 }); + zones.add(new float[] { x0, y0, x1, y1 }); } break; case GOTO_TARGET: @@ -175,23 +175,23 @@ public RRMapFileParser(byte[] raw) { Float y0 = getUInt16(raw, blockDataStart + wallPair * 8 + 2) / MM - top; Float x1 = offset - (getUInt16(raw, blockDataStart + wallPair * 8 + 4)) / MM; Float y1 = getUInt16(raw, blockDataStart + wallPair * 8 + 6) / MM - top; - walls.add(new Float[] { x0, y0, x1, y1 }); + walls.add(new float[] { x0, y0, x1, y1 }); } break; case NO_GO_AREAS: case MFBZS_AREA: int areaPairs = getUInt16(header, 0x08); - ArrayList area = new ArrayList(); + ArrayList area = new ArrayList(); for (int areaPair = 0; areaPair < areaPairs; areaPair++) { - Float x0 = offset - (getUInt16(raw, blockDataStart + areaPair * 16)) / MM; - Float y0 = getUInt16(raw, blockDataStart + areaPair * 16 + 2) / MM - top; - Float x1 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 4)) / MM; - Float y1 = getUInt16(raw, blockDataStart + areaPair * 16 + 6) / MM - top; - Float x2 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 8)) / MM; - Float y2 = getUInt16(raw, blockDataStart + areaPair * 16 + 10) / MM - top; - Float x3 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 12)) / MM; - Float y3 = getUInt16(raw, blockDataStart + areaPair * 16 + 14) / MM - top; - area.add(new Float[] { x0, y0, x1, y1, x2, y2, x3, y3 }); + float x0 = offset - (getUInt16(raw, blockDataStart + areaPair * 16)) / MM; + float y0 = getUInt16(raw, blockDataStart + areaPair * 16 + 2) / MM - top; + float x1 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 4)) / MM; + float y1 = getUInt16(raw, blockDataStart + areaPair * 16 + 6) / MM - top; + float x2 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 8)) / MM; + float y2 = getUInt16(raw, blockDataStart + areaPair * 16 + 10) / MM - top; + float x3 = offset - (getUInt16(raw, blockDataStart + areaPair * 16 + 12)) / MM; + float y3 = getUInt16(raw, blockDataStart + areaPair * 16 + 14) / MM - top; + area.add(new float[] { x0, y0, x1, y1, x2, y2, x3, y3 }); } areas.put(Integer.valueOf(blocktype & 0xFF), area); break; @@ -213,12 +213,12 @@ public RRMapFileParser(byte[] raw) { printBlockDetails = true; } if (logger.isTraceEnabled() || printBlockDetails) { - logger.info("Blocktype: {}", Integer.toString(blocktype)); - logger.info("Header len: {} data len: {} ", Integer.toString(blockHeaderLength), + logger.debug("Blocktype: {}", Integer.toString(blocktype)); + logger.debug("Header len: {} data len: {} ", Integer.toString(blockHeaderLength), Integer.toString(blockDataLength)); - logger.info("H: {}", Utils.getSpacedHex(header)); + logger.debug("H: {}", Utils.getSpacedHex(header)); if (blockDataLength > 0) { - logger.info("D: {}", (blockDataLength < 60 ? Utils.getSpacedHex(data) + logger.debug("D: {}", (blockDataLength < 60 ? Utils.getSpacedHex(data) : Utils.getSpacedHex(getBytes(data, 0, 60)))); } printBlockDetails = false; @@ -356,7 +356,7 @@ public int getLeft() { return left; } - public ArrayList getZones() { + public ArrayList getZones() { return zones; } @@ -388,7 +388,7 @@ public int getRoboA() { return roboA; } - public Map> getPaths() { + public Map> getPaths() { return paths; } @@ -396,11 +396,11 @@ public Map> getPathsDetails() { return pathsDetails; } - public ArrayList getWalls() { + public ArrayList getWalls() { return walls; } - public Map> getAreas() { + public Map> getAreas() { return areas; } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java index 7cb7cda016a85..f686b4904cfc4 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java @@ -146,8 +146,12 @@ public int queueCommand(String command, String params) MiIoSendCommand sendCmd = new MiIoSendCommand(cmdId, MiIoCommand.getCommand(command), fullCommand.toString()); concurrentLinkedQueue.add(sendCmd); + String tokenText = Utils.getHex(token); // Obfuscate part of the token to allow sharing of the logfiles + tokenText = tokenText.substring(0, 8) + .concat((tokenText.length() > 8) ? tokenText.substring(8, 24).replaceAll(".", "X") : "") + .concat((tokenText.length() > 24) ? tokenText.substring(24) : ""); logger.debug("Command added to Queue {} -> {} (Device: {} token: {} Queue: {})", fullCommand.toString(), ip, - Utils.getHex(deviceId), Utils.getHex(token), concurrentLinkedQueue.size()); + Utils.getHex(deviceId), tokenText, concurrentLinkedQueue.size()); if (needPing) { sendPing(ip); } diff --git a/bundles/org.openhab.binding.miio/src/main/resources/images/charger.png b/bundles/org.openhab.binding.miio/src/main/resources/images/charger.png new file mode 100644 index 0000000000000000000000000000000000000000..d94e0138ac36ea5dc67aa405b2792497b4af61f9 GIT binary patch literal 1403 zcmeAS@N?(olHy`uVBq!ia0vp^S|H593?x6vT4pjZFf#=Bgt)pF__#jn8zkue|NkHU ze%tl!YTBzsU;q8geZB1UpU?5H=KcHs@5P^wE$_CJzg@Ha>*enEJJ0-hu<+BdgWqp{ z`}foRS%2KixpO`pe)9YMoez)pY1nV&Th8$LFOZRmX& zIzQ)Sdy2P{nZqPQ*Z-;WCOy=CvaWnWj?0}2zg+^`>@)UUUwrKK?qEx{7%Ro>w_guQ zeb~8v!UO)i<($ELgq|zEQ0>_Hk{cssefBPyt-R@ z;&M~V$|bu#aa{>r&H2S@b@Ao8w@h|M*j7TEw2-& zs}%mu4qV9eESW?5+Jx577gMvtPOjKCd85N2!#u0ALGsNH^aJ-EEM#As-B6U}d*%w$ zp^4MZ#PVFtuIDOM`NJvLw(EziP?{_A`fZ!mospQk^2xV7CNsP}BORvv2|MvL^{Mg( z*Qd|tv)u-CGpNRjuH~Q!W m-^&LNeoC0XlG$aIUTO@?7By$~&H}elF{r5}E*Zc<9Ce literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.miio/src/main/resources/images/ohlogo.png b/bundles/org.openhab.binding.miio/src/main/resources/images/ohlogo.png new file mode 100644 index 0000000000000000000000000000000000000000..0f79fdaee7cf42263590b8c98782011433667c29 GIT binary patch literal 2792 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 Date: Wed, 1 Apr 2020 20:59:17 +0200 Subject: [PATCH 04/10] [miio] schedule login to avoid activation issues Signed-off-by: Marcel Verpaalen --- .../miio/internal/MiIoHandlerFactory.java | 8 ++- .../miio/internal/cloud/MiCloudConnector.java | 2 +- .../internal/handler/MiIoAbstractHandler.java | 1 - .../miio/internal/robot/RRMapFileParser.java | 52 ++++++++++--------- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java index 13daa15ec2bff..1213b0a5df577 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java @@ -15,9 +15,12 @@ import static org.openhab.binding.miio.internal.MiIoBindingConstants.*; import java.util.Dictionary; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.common.ThreadPoolManager; import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingTypeUID; import org.eclipse.smarthome.core.thing.binding.BaseThingHandlerFactory; @@ -43,6 +46,9 @@ @Component(service = ThingHandlerFactory.class, configurationPid = "binding.miio") @NonNullByDefault public class MiIoHandlerFactory extends BaseThingHandlerFactory { + private static final String THING_HANDLER_THREADPOOL_NAME = "thingHandler"; + protected final ScheduledExecutorService scheduler = ThreadPoolManager + .getScheduledPool(THING_HANDLER_THREADPOOL_NAME); private MiIoDatabaseWatchService miIoDatabaseWatchService; private CloudConnector cloudConnector; @@ -64,7 +70,7 @@ protected void activate(ComponentContext componentContext) { String password = (String) properties.get("password"); @Nullable String country = (String) properties.get("country"); - cloudConnector.setCredentials(username, password, country); + scheduler.schedule(() -> cloudConnector.setCredentials(username, password, country), 0, TimeUnit.SECONDS); } @Override diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java index b628f51bf9d66..e4843adc23c9d 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -325,7 +325,7 @@ private String loginStep1() throws InterruptedException, TimeoutException, Execu String sign = resp.getAsJsonObject().get("_sign").getAsString(); logger.trace("Xiaomi Login step 1 sign = {}", sign); return sign; - } catch (JsonSyntaxException e) { + } catch (JsonSyntaxException | NullPointerException e) { throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e); } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java index bb0f02a0313aa..314102255a9f2 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java @@ -63,7 +63,6 @@ public abstract class MiIoAbstractHandler extends BaseThingHandler implements Mi protected static final int MAX_QUEUE = 5; protected @Nullable ScheduledFuture pollingJob; - // protected MiIoBindingConfiguration configuration; protected MiIoDevices miDevice = MiIoDevices.UNKNOWN; protected boolean isIdentified; diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java index 3a3d6d020a388..c78edecf3716f 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java @@ -154,10 +154,10 @@ public RRMapFileParser(byte[] raw) { case CURRENTLY_CLEANED_ZONES: int zonePairs = getUInt16(header, 0x08); for (int zonePair = 0; zonePair < zonePairs; zonePair++) { - Float x0 = offset - (getUInt16(raw, blockDataStart + zonePair * 8)) / MM; - Float y0 = getUInt16(raw, blockDataStart + zonePair * 8 + 2) / MM - top; - Float x1 = offset - (getUInt16(raw, blockDataStart + zonePair * 8 + 4)) / MM; - Float y1 = getUInt16(raw, blockDataStart + zonePair * 8 + 6) / MM - top; + float x0 = offset - (getUInt16(raw, blockDataStart + zonePair * 8)) / MM; + float y0 = getUInt16(raw, blockDataStart + zonePair * 8 + 2) / MM - top; + float x1 = offset - (getUInt16(raw, blockDataStart + zonePair * 8 + 4)) / MM; + float y1 = getUInt16(raw, blockDataStart + zonePair * 8 + 6) / MM - top; zones.add(new float[] { x0, y0, x1, y1 }); } break; @@ -171,10 +171,10 @@ public RRMapFileParser(byte[] raw) { case VIRTUAL_WALLS: int wallPairs = getUInt16(header, 0x08); for (int wallPair = 0; wallPair < wallPairs; wallPair++) { - Float x0 = offset - (getUInt16(raw, blockDataStart + wallPair * 8)) / MM; - Float y0 = getUInt16(raw, blockDataStart + wallPair * 8 + 2) / MM - top; - Float x1 = offset - (getUInt16(raw, blockDataStart + wallPair * 8 + 4)) / MM; - Float y1 = getUInt16(raw, blockDataStart + wallPair * 8 + 6) / MM - top; + float x0 = offset - (getUInt16(raw, blockDataStart + wallPair * 8)) / MM; + float y0 = getUInt16(raw, blockDataStart + wallPair * 8 + 2) / MM - top; + float x1 = offset - (getUInt16(raw, blockDataStart + wallPair * 8 + 4)) / MM; + float y1 = getUInt16(raw, blockDataStart + wallPair * 8 + 6) / MM - top; walls.add(new float[] { x0, y0, x1, y1 }); } break; @@ -271,30 +271,32 @@ private int getUInt16(byte[] bytes, int pos) { @Override public String toString() { - String s = "RR Map:\tMajor Version: " + majorVersion + " Minor version: " + minorVersion + " Map Index: " - + mapIndex + " Map Sequence: " + mapSequence + " \r\n"; - s += "Image:\tsize: " + Integer.toString(imageSize) + "\ttop: " + Integer.toString(top) + "\tleft: " + StringBuilder sb = new StringBuilder(); + sb.append("RR Map:\tMajor Version: " + majorVersion + " Minor version: " + minorVersion + " Map Index: " + + mapIndex + " Map Sequence: " + mapSequence + " \r\n"); + sb.append("Image:\tsize: " + Integer.toString(imageSize) + "\ttop: " + Integer.toString(top) + "\tleft: " + Integer.toString(left) + " height: " + Integer.toString(imgHeight) + " width: " - + Integer.toString(imgWidth) + "\r\n"; - s += "Charger pos:\tX: " + Float.toString(getChargerX()) + "\tY: " + Float.toString(getChargerY()) + "\r\n"; - s += "Robo pos:\tX: " + Float.toString(getRoboX()) + "\tY: " + Float.toString(getRoboY()) + " Angle: " - + Float.toString(getRoboA()) + "\r\n"; - s += "Goto:\tX: " + Float.toString(getGotoX()) + "\tY: " + Float.toString(getGotoY()) + "\r\n"; + + Integer.toString(imgWidth) + "\r\n"); + sb.append( + "Charger pos:\tX: " + Float.toString(getChargerX()) + "\tY: " + Float.toString(getChargerY()) + "\r\n"); + sb.append("Robo pos:\tX: " + Float.toString(getRoboX()) + "\tY: " + Float.toString(getRoboY()) + " Angle: " + + Float.toString(getRoboA()) + "\r\n"); + sb.append("Goto:\tX: " + Float.toString(getGotoX()) + "\tY: " + Float.toString(getGotoY()) + "\r\n"); for (Integer area : areas.keySet()) { - s += (area == NO_GO_AREAS ? "No Go zones:\t" : "MFBZS zones:\t") + Integer.toString(areas.get(area).size()) - + "\r\n"; + sb.append((area == NO_GO_AREAS ? "No Go zones:\t" : "MFBZS zones:\t") + + Integer.toString(areas.get(area).size()) + "\r\n"); } - s += "Walls:\t" + Integer.toString(walls.size()) + "\r\n"; - s += "Obstacles:\t" + Integer.toString(obstacles.size()) + "\r\n"; - s += "Blocks:\t" + Integer.toString(blocks.length) + "\r\n"; - s += "Paths:"; + sb.append("Walls:\t" + Integer.toString(walls.size()) + "\r\n"); + sb.append("Obstacles:\t" + Integer.toString(obstacles.size()) + "\r\n"); + sb.append("Blocks:\t" + Integer.toString(blocks.length) + "\r\n"); + sb.append("Paths:"); for (Integer p : pathsDetails.keySet()) { - s += "\r\nPath type:\t" + Integer.toString(p); + sb.append("\r\nPath type:\t" + Integer.toString(p)); for (String detail : pathsDetails.get(p).keySet()) { - s += " " + detail + ": " + Integer.toString(pathsDetails.get(p).get(detail)); + sb.append(" " + detail + ": " + Integer.toString(pathsDetails.get(p).get(detail))); } } - return s; + return sb.toString(); } /** From c0c7db574febd608d2861ac23b50b19027171c57 Mon Sep 17 00:00:00 2001 From: Marcel Verpaalen Date: Fri, 3 Apr 2020 19:41:34 +0200 Subject: [PATCH 05/10] [miio] updates based on feedback Fix for re-logon move to DTO Updates based on review feedback Signed-off-by: Marcel Verpaalen --- .../miio/internal/MiIoHandlerFactory.java | 3 +- .../miio/internal/cloud/CloudConnector.java | 74 ++---- .../miio/internal/cloud/CloudDeviceDTO.java | 210 ++++++++++++++++++ .../internal/cloud/CloudDeviceListDTO.java | 34 +++ .../miio/internal/cloud/CloudLoginDTO.java | 126 +++++++++++ .../miio/internal/cloud/CloudUtil.java | 29 --- .../miio/internal/cloud/MiCloudConnector.java | 133 ++++++++--- .../internal/discovery/MiIoDiscovery.java | 17 +- .../internal/handler/MiIoAbstractHandler.java | 2 + .../internal/handler/MiIoBasicHandler.java | 5 +- .../internal/handler/MiIoVacuumHandler.java | 18 +- .../miio/internal/robot/RRMapDraw.java | 29 +-- .../miio/internal/robot/RRMapFileParser.java | 44 ++-- .../transport/MiIoAsyncCommunication.java | 17 +- 14 files changed, 567 insertions(+), 174 deletions(-) create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceDTO.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java create mode 100644 bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java index 1213b0a5df577..eb587e2a7ab4c 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java @@ -16,7 +16,6 @@ import java.util.Dictionary; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -70,7 +69,7 @@ protected void activate(ComponentContext componentContext) { String password = (String) properties.get("password"); @Nullable String country = (String) properties.get("country"); - scheduler.schedule(() -> cloudConnector.setCredentials(username, password, country), 0, TimeUnit.SECONDS); + scheduler.submit(() -> cloudConnector.setCredentials(username, password, country)); } @Override diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java index 782493f3c414b..7c24254daa724 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java @@ -32,10 +32,7 @@ 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; /** * The {@link CloudConnector} is responsible for connecting OH to the Xiaomi cloud communication. @@ -51,17 +48,16 @@ public class CloudConnector { private static final int STARTING = 0; private static final int REFRESHING = 1; private static final int AVAILABLE = 2; - private int deviceListState = STARTING; + private volatile int deviceListState = STARTING; private String username = ""; private String password = ""; private String country = "ru,us,tw,sg,cn,de"; - private List deviceList = new ArrayList(); + private List deviceList = new ArrayList<>(); private boolean connected; private final HttpClient httpClient; private @Nullable MiCloudConnector cloudConnector; private final Logger logger = LoggerFactory.getLogger(CloudConnector.class); - private final JsonParser parser = new JsonParser(); private ExpiringCache logonCache = new ExpiringCache(CACHE_EXPIRY, () -> { return logon(); @@ -79,13 +75,7 @@ public class CloudConnector { deviceList.clear(); for (String server : country.split(",")) { try { - JsonElement response = parser.parse(cl.getDevices(server)); - if (response.isJsonObject() && response.getAsJsonObject().has("result") - && response.getAsJsonObject().get("result").isJsonObject()) { - JsonObject result = response.getAsJsonObject().get("result").getAsJsonObject(); - result.addProperty("server", server); - deviceList.add(result); - } + cl.getDevices(server); } catch (JsonParseException e) { logger.debug("Parsing error getting devices: {}", e.getMessage()); } @@ -188,55 +178,35 @@ private boolean logon() { return connected; } - public List getDevicesList() { + public List getDevicesList() { refreshDeviceList.getValue(); return deviceList; } - public JsonObject getDeviceInfo(String id) { + public @Nullable CloudDeviceDTO getDeviceInfo(String id) { getDevicesList(); if (deviceListState < AVAILABLE) { - JsonObject returnvalue = new JsonObject(); - returnvalue.addProperty("deviceListState", deviceListState); - return returnvalue; + return null; } String did = Long.toString(Long.parseUnsignedLong(id, 16)); - List devicedata = new ArrayList(); - for (JsonObject countyDeviceList : deviceList) { - if (countyDeviceList.has("list") && countyDeviceList.get("list").isJsonArray()) { - for (JsonElement device : countyDeviceList.get("list").getAsJsonArray()) { - if (device.isJsonObject() && device.getAsJsonObject().has("did") - && device.getAsJsonObject().get("did").getAsString().contentEquals(did) - && device.getAsJsonObject().has("token")) { - JsonObject deviceDetails = device.getAsJsonObject(); - deviceDetails.addProperty("server", countyDeviceList.get("server").getAsString()); - devicedata.add(deviceDetails); - } - } + List devicedata = new ArrayList<>(); + for (CloudDeviceDTO deviceDetails : deviceList) { + if (deviceDetails.getDid().contentEquals(did)) { + devicedata.add(deviceDetails); } } - JsonObject returnvalue = new JsonObject(); - switch (devicedata.size()) { - case 0: - returnvalue.addProperty("connected", connected); - break; - case 1: - returnvalue = devicedata.get(0); - break; - default: - for (JsonObject device : devicedata) { - if (device.has("isOnline") && device.get("isOnline").getAsBoolean()) { - return device; - } - } - logger.debug("Found multiple servers for device, with device offline {} {} ", - devicedata.get(0).get("name").getAsString(), id); - for (JsonObject device : devicedata) { - logger.debug("Server {} token: {}", device.get("server").getAsString(), - device.get("token").getAsString()); - } - returnvalue = devicedata.get(0); + if (devicedata.isEmpty()) { + return null; + } + for (CloudDeviceDTO device : devicedata) { + if (device.getIsOnline()) { + return device; + } + } + if (devicedata.size() > 1) { + logger.debug("Found multiple servers for device {} {} returning first", devicedata.get(0).getDid(), + devicedata.get(0).getName()); } - return returnvalue; + return devicedata.get(0); } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceDTO.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceDTO.java new file mode 100644 index 0000000000000..c4a7ecf870f9a --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceDTO.java @@ -0,0 +1,210 @@ +/** + * Copyright (c) 2010-2020 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.NonNull; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * This DTO class wraps the device info json structure + * + * @author Marcel Verpaalen - Initial contribution + */ +public class CloudDeviceDTO { + + @SerializedName("did") + @Expose + private String did; + @SerializedName("token") + @Expose + private String token; + @SerializedName("longitude") + @Expose + private String longitude; + @SerializedName("latitude") + @Expose + private String latitude; + @SerializedName("name") + @Expose + private String name; + @SerializedName("pid") + @Expose + private String pid; + @SerializedName("localip") + @Expose + private String localip; + @SerializedName("mac") + @Expose + private String mac; + @SerializedName("ssid") + @Expose + private String ssid; + @SerializedName("bssid") + @Expose + private String bssid; + @SerializedName("parent_id") + @Expose + private String parentId; + @SerializedName("parent_model") + @Expose + private String parentModel; + @SerializedName("show_mode") + @Expose + private Integer showMode; + @SerializedName("model") + @Expose + private String model; + @SerializedName("adminFlag") + @Expose + private Integer adminFlag; + @SerializedName("shareFlag") + @Expose + private Integer shareFlag; + @SerializedName("permitLevel") + @Expose + private Integer permitLevel; + @SerializedName("isOnline") + @Expose + private Boolean isOnline; + @SerializedName("desc") + @Expose + private String desc; + @SerializedName("uid") + @Expose + private Integer uid; + @SerializedName("pd_id") + @Expose + private Integer pdId; + @SerializedName("password") + @Expose + private String password; + @SerializedName("rssi") + @Expose + private Integer rssi; + @SerializedName("family_id") + @Expose + private Integer familyId; + private @NonNull String server = "undefined"; + + public @NonNull String getDid() { + return did != null ? did : ""; + } + + public @NonNull String getToken() { + return token != null ? token : ""; + } + + public String getLongitude() { + return longitude; + } + + public String getLatitude() { + return latitude; + } + + public @NonNull String getName() { + return name != null ? name : ""; + } + + public String getPid() { + return pid; + } + + public @NonNull String getLocalip() { + return localip != null ? localip : ""; + } + + public String getMac() { + return mac; + } + + public String getSsid() { + return ssid; + } + + public String getBssid() { + return bssid; + } + + public String getParentId() { + return parentId; + } + + public String getParentModel() { + return parentModel; + } + + public Integer getShowMode() { + return showMode; + } + + public String getModel() { + return model; + } + + public Integer getAdminFlag() { + return adminFlag; + } + + public Integer getShareFlag() { + return shareFlag; + } + + public Integer getPermitLevel() { + return permitLevel; + } + + public Boolean getIsOnline() { + return isOnline; + } + + public String getDesc() { + return desc; + } + + public Integer getUid() { + return uid; + } + + public Integer getPdId() { + return pdId; + } + + public String getPassword() { + return password; + } + + public Integer getRssi() { + return rssi; + } + + public Integer getFamilyId() { + return familyId; + } + + public @NonNull String getServer() { + return server; + } + + public void setServer(@NonNull String server) { + this.server = server; + } + + @Override + public String toString() { + return "Device name: '" + getName() + "', did: '" + getDid() + "', token: '" + getToken() + "', ip: " + + getLocalip() + ", server: " + server; + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java new file mode 100644 index 0000000000000..8c2592bce2620 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2020 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.util.List; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * This DTO class wraps the device list info json structure + * + * @author Marcel Verpaalen - Initial contribution + */ +public class CloudDeviceListDTO { + + @SerializedName("list") + @Expose + private List cloudDevices = null; + + public java.util.List getCloudDevices() { + return cloudDevices; + } +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java new file mode 100644 index 0000000000000..ba5958f9addf6 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2010-2020 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.jetbrains.annotations.NotNull; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * This DTO class wraps the login step 2 json structure + * + * @author Marcel Verpaalen - Initial contribution + */ +public class CloudLoginDTO { + + @SerializedName("qs") + @Expose + private String qs; + @SerializedName("psecurity") + @Expose + private String psecurity; + @SerializedName("nonce") + @Expose + private Integer nonce; + @SerializedName("ssecurity") + @Expose + private String ssecurity; + @SerializedName("passToken") + @Expose + private String passToken; + @SerializedName("userId") + @Expose + private String userId; + @SerializedName("cUserId") + @Expose + private String cUserId; + @SerializedName("securityStatus") + @Expose + private Integer securityStatus; + @SerializedName("pwd") + @Expose + private Integer pwd; + @SerializedName("code") + @Expose + private String code; + @SerializedName("desc") + @Expose + private String desc; + @SerializedName("location") + @Expose + private String location; + @SerializedName("captchaUrl") + @Expose + private Object captchaUrl; + + public @NotNull String getSsecurity() { + return ssecurity != null ? ssecurity : ""; + } + + public @NotNull String getUserId() { + return userId != null ? userId : ""; + } + + public @NotNull String getcUserId() { + return cUserId != null ? cUserId : ""; + } + + public @NotNull String getPassToken() { + return passToken != null ? passToken : ""; + } + + public @NotNull String getLocation() { + return location != null ? location : ""; + } + + public String getCode() { + return code; + } + + public String getQs() { + return qs; + } + + public String getPsecurity() { + return psecurity; + } + + public Integer getNonce() { + return nonce; + } + + public String getCUserId() { + return cUserId; + } + + public Integer getSecurityStatus() { + return securityStatus; + } + + public Integer getPwd() { + return pwd; + } + + public String getDesc() { + return desc; + } + + public Object getCaptchaUrl() { + return captchaUrl; + } + + public void setCaptchaUrl(Object captchaUrl) { + this.captchaUrl = captchaUrl; + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java index 048f9a8a56b96..074adaab400a2 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java @@ -37,9 +37,6 @@ import org.slf4j.Logger; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; /** * The {@link CloudUtil} class is used for supporting functions for Xiaomi cloud access @@ -61,32 +58,6 @@ public static String getElementString(JsonElement jsonElement, String element, L return value; } - public static void printDevices(String response, String country, Logger logger) { - try { - final JsonElement resp = new JsonParser().parse(response); - if (resp.isJsonObject()) { - final JsonObject jor = resp.getAsJsonObject(); - String result = jor.get("result").getAsJsonObject().toString(); - for (JsonElement di : jor.get("result").getAsJsonObject().get("list").getAsJsonArray()) { - if (di.isJsonObject()) { - final JsonObject deviceInfo = di.getAsJsonObject(); - logger.debug( - "Xiaomi cloud info: device name: '{}', did: '{}', token: '{}', ip: {}, server: {} ", - deviceInfo.get("name").getAsString(), deviceInfo.get("did").getAsString(), - deviceInfo.get("token").getAsString(), deviceInfo.get("localip").getAsString(), - country); - } - } - logger.trace("Devices: {}", result); - } else { - logger.debug("Response is not a json object: '{}'", response); - } - } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) { - logger.info("Error while printing devices: {}", e.getMessage()); - } - - } - public static void saveFile(String data, String country, Logger logger) { String dbFolderName = ConfigConstants.getUserDataFolder() + File.separator + MiIoBindingConstants.BINDING_ID; File folder = new File(dbFolderName); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java index e4843adc23c9d..64c6fe2202032 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -18,11 +18,16 @@ import java.net.MalformedURLException; import java.net.URI; import java.net.URL; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Random; +import java.util.TimeZone; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -43,6 +48,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -63,7 +70,11 @@ public class MiCloudConnector { private static final String USERAGENT = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-" + AGENT_ID + " APP/xiaomi.smarthome APPV/62830"; private static Locale locale = Locale.getDefault(); - private final JsonParser parser = new JsonParser(); + private static final TimeZone TZ = TimeZone.getDefault(); + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("OOOO"); + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private static final JsonParser PARSER = new JsonParser(); + private final String clientId; private String username; @@ -85,14 +96,15 @@ public MiCloudConnector(String username, String password, HttpClient httpClient) } clientId = (new Random().ints(97, 122 + 1).limit(6) .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString()); + } void startClient() throws MiCloudException { if (!httpClient.isStarted()) { try { httpClient.start(); - // set default cookies CookieStore cookieStore = httpClient.getCookieStore(); + // set default cookies addCookie(cookieStore, "sdkVersion", "accountsdk-18.8.15", "mi.com"); addCookie(cookieStore, "sdkVersion", "accountsdk-18.8.15", "xiaomi.com"); addCookie(cookieStore, "deviceId", this.clientId, "mi.com"); @@ -143,7 +155,7 @@ public String getMapUrl(String vacuumMap, String country) throws MiCloudExceptio String mapResponse = request(url, map); logger.trace("response: {}", mapResponse); String errorMsg = ""; - JsonElement response = parser.parse(mapResponse); + JsonElement response = PARSER.parse(mapResponse); if (response.isJsonObject()) { logger.debug("Received JSON message {}", response.toString()); if (response.getAsJsonObject().has("result") && response.getAsJsonObject().get("result").isJsonObject()) { @@ -172,7 +184,33 @@ public String getDeviceStatus(String device, String country) throws MiCloudExcep return response; } - public String getDevices(String country) { + public List getDevices(String country) { + final String response = getDeviceString(country); + List devicesList = new ArrayList<>(); + try { + final JsonElement resp = PARSER.parse(response); + if (resp.isJsonObject()) { + final JsonObject jor = resp.getAsJsonObject(); + if (jor.has("result")) { + devicesList = GSON.fromJson(jor.get("result"), CloudDeviceListDTO.class).getCloudDevices(); + + for (CloudDeviceDTO device : devicesList) { + device.setServer(country); + logger.debug("Xiaomi cloud info: {}", device); + } + } else { + logger.debug("Response missing result: '{}'", response); + } + } else { + logger.debug("Response is not a json object: '{}'", response); + } + } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) { + logger.info("Error while parsing devices: {}", e.getMessage()); + } + return devicesList; + } + + public String getDeviceString(String country) { String url = getApiUrl(country) + "/home/device_list"; Map map = new HashMap(); map.put("data", "{\"getVirtualModel\":false,\"getHuamiDevices\":0}"); @@ -180,7 +218,6 @@ public String getDevices(String country) { try { resp = request(url, map); logger.trace("Get devices response: {}", resp); - CloudUtil.printDevices(resp, country, logger); if (resp.length() > 2) { CloudUtil.saveFile(resp, country, logger); return resp; @@ -212,14 +249,16 @@ public String request(String url, Map params) throws MiCloudExce request.cookie(new HttpCookie("yetAnotherServiceToken", this.serviceToken)); request.cookie(new HttpCookie("serviceToken", this.serviceToken)); request.cookie(new HttpCookie("locale", locale.toString())); - request.cookie(new HttpCookie("timezone", "GMT%2B01%3A00")); - request.cookie(new HttpCookie("is_daylight", "1")); - request.cookie(new HttpCookie("dst_offset", "3600000")); + request.cookie(new HttpCookie("timezone", ZonedDateTime.now().format(FORMATTER))); + request.cookie(new HttpCookie("is_daylight", TZ.inDaylightTime(new Date()) ? "1" : "0")); + request.cookie(new HttpCookie("dst_offset", Integer.toString(TZ.getDSTSavings()))); request.cookie(new HttpCookie("channel", "MI_APP_STORE")); - for (HttpCookie cookie : request.getCookies()) { - logger.trace("Cookie set for request ({}) : {} --> {} (path: {})", cookie.getDomain(), cookie.getName(), - cookie.getValue(), cookie.getPath()); + if (logger.isTraceEnabled()) { + for (HttpCookie cookie : request.getCookies()) { + logger.trace("Cookie set for request ({}) : {} --> {} (path: {})", cookie.getDomain(), + cookie.getName(), cookie.getValue(), cookie.getPath()); + } } String method = "POST"; request.method(method); @@ -237,6 +276,9 @@ public String request(String url, Map params) throws MiCloudExce logger.trace("fieldcontent: {}", fields.toString()); final ContentResponse response = request.send(); + if (response.getStatus() == HttpStatus.FORBIDDEN_403) { + this.serviceToken = ""; + } return response.getContentAsString(); } catch (HttpResponseException e) { serviceToken = ""; @@ -256,6 +298,14 @@ private void addCookie(CookieStore cookieStore, String name, String value, Strin cookieStore.add(URI.create("https://" + domain), cookie); } + private void removeCookies(CookieStore cookieStore, String domain) { + URI uri = URI.create(domain); + final List cookies = cookieStore.get(uri); + for (HttpCookie cookie : cookies) { + cookieStore.remove(uri, cookie); + } + } + public synchronized boolean login() { if (!checkCredentials()) { return false; @@ -284,7 +334,12 @@ protected boolean loginRequest() throws MiCloudException { try { startClient(); String sign = loginStep1(); - String location = loginStep2(sign); + String location; + if (!sign.startsWith("http")) { + location = loginStep2(sign); + } else { + location = sign; // seems we already have login location + } final ContentResponse responseStep3 = loginStep3(location); switch (responseStep3.getStatus()) { @@ -322,9 +377,15 @@ private String loginStep1() throws InterruptedException, TimeoutException, Execu logger.trace("Xiaomi Login step 1 response = {}", responseStep1); try { JsonElement resp = new JsonParser().parse(parseJson(content)); - String sign = resp.getAsJsonObject().get("_sign").getAsString(); - logger.trace("Xiaomi Login step 1 sign = {}", sign); - return sign; + if (resp.getAsJsonObject().has("_sign")) { + String sign = resp.getAsJsonObject().get("_sign").getAsString(); + logger.trace("Xiaomi Login step 1 sign = {}", sign); + return sign; + } else { + logger.trace("Xiaomi Login _sign missing. Maybe still has login cookie."); + return ""; + } + } catch (JsonSyntaxException | NullPointerException e) { throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e); } @@ -344,12 +405,13 @@ private String loginStep2(String sign) Fields fields = new Fields(); fields.put("sid", "xiaomiio"); - // fields.put("hash", encodePassword(password)); 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); - fields.put("_sign", sign); + if (!sign.isEmpty()) { + fields.put("_sign", sign); + } fields.put("_json", "true"); request.content(new FormContentProvider(fields)); @@ -360,12 +422,14 @@ private String loginStep2(String sign) logger.trace("Xiaomi login step 2 content = {}", content2); JsonElement resp2 = new JsonParser().parse(parseJson(content2)); - ssecurity = CloudUtil.getElementString(resp2, "ssecurity", logger); - userId = CloudUtil.getElementString(resp2, "userId", logger); - cUserId = CloudUtil.getElementString(resp2, "cUserId", logger); - passToken = CloudUtil.getElementString(resp2, "passToken", logger); - String location = CloudUtil.getElementString(resp2, "location", logger); - String code = CloudUtil.getElementString(resp2, "code", logger); + CloudLoginDTO jsonResp = GSON.fromJson(resp2, CloudLoginDTO.class); + + 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); @@ -404,12 +468,23 @@ private ContentResponse loginStep3(String location) } private void dumpCookies(String url) { - URI uri = URI.create(url); - logger.trace("Cookie dump for {}", url); - List cookies = httpClient.getCookieStore().get(uri); - for (HttpCookie cookie : cookies) { - logger.trace("Cookie ({}) : {} --> {} (path: {})", cookie.getDomain(), cookie.getName(), - cookie.getValue(), cookie.getPath()); + if (logger.isTraceEnabled()) { + try { + URI uri = URI.create(url); + if (uri != null) { + logger.trace("Cookie dump for {}", uri); + CookieStore cs = httpClient.getCookieStore(); + List cookies = cs.get(uri); + for (HttpCookie cookie : cookies) { + logger.trace("Cookie ({}) : {} --> {} (path: {})", cookie.getDomain(), cookie.getName(), + cookie.getValue(), cookie.getPath()); + } + } else { + logger.trace("Could not create URI from {}", url); + } + } catch (IllegalArgumentException | NullPointerException e) { + logger.trace("Error dumping cookies from {}: ", url, e.getMessage(), e); + } } } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java index 00062ba7487a1..e1f8e68a107b3 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/discovery/MiIoDiscovery.java @@ -36,14 +36,13 @@ import org.openhab.binding.miio.internal.Message; import org.openhab.binding.miio.internal.Utils; import org.openhab.binding.miio.internal.cloud.CloudConnector; +import org.openhab.binding.miio.internal.cloud.CloudDeviceDTO; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.JsonObject; - /** * The {@link MiIoDiscovery} is responsible for discovering new Xiaomi Mi IO devices * and their token @@ -144,13 +143,13 @@ private void discovered(String ip, byte[] response) { boolean isOnline = false; if (cloudConnector.isConnected()) { cloudConnector.getDevicesList(); - JsonObject cloudInfo = cloudConnector.getDeviceInfo(id); - logger.debug("Cloud Response: {}", cloudInfo.toString()); - if (cloudInfo.has("token")) { - token = cloudInfo.get("token").getAsString(); - label = cloudInfo.get("name").getAsString() + " " + id + " (" + Long.parseUnsignedLong(id, 16) + ")"; - country = cloudInfo.get("server").getAsString(); - isOnline = cloudInfo.get("isOnline").getAsBoolean(); + CloudDeviceDTO cloudInfo = cloudConnector.getDeviceInfo(id); + if (cloudInfo != null) { + logger.debug("Cloud Info: {}", cloudInfo); + token = cloudInfo.getToken(); + label = cloudInfo.getName() + " " + id + " (" + Long.parseUnsignedLong(id, 16) + ")"; + country = cloudInfo.getServer(); + isOnline = cloudInfo.getIsOnline(); } } ThingUID uid = new ThingUID(THING_TYPE_MIIO, id); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java index 314102255a9f2..ef884196a1f8f 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java @@ -69,6 +69,7 @@ public abstract class MiIoAbstractHandler extends BaseThingHandler implements Mi protected JsonParser parser; protected byte[] token = new byte[0]; + protected @Nullable MiIoBindingConfiguration configuration; protected @Nullable MiIoAsyncCommunication miioCom; protected int lastId; @@ -100,6 +101,7 @@ public void initialize() { logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(), getThing().getThingTypeUID()); final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); + this.configuration = configuration; if (!tokenCheckPass(configuration.token)) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token"); return; diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java index 49c2c171df617..45ba12c72be8f 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoBasicHandler.java @@ -246,7 +246,10 @@ private void sendRefreshProperties(MiIoCommand command, JsonArray getPropString) * Checks if the channel structure has been build already based on the model data. If not build it. */ private void checkChannelStructure() { - final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); + final MiIoBindingConfiguration configuration = this.configuration; + if (configuration == null) { + return; + } if (!hasChannelStructure) { if (configuration.model == null || configuration.model.isEmpty()) { logger.debug("Model needs to be determined"); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java index 190a117910760..c8802bf4d1c86 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java @@ -72,6 +72,9 @@ public class MiIoVacuumHandler extends MiIoAbstractHandler { private final Logger logger = LoggerFactory.getLogger(MiIoVacuumHandler.class); private static final float MAP_SCALE = 2.0f; + private static final SimpleDateFormat DATEFORMATTER = new SimpleDateFormat("yyyyMMdd-HHss"); + private static final String MAP_PATH = ConfigConstants.getUserDataFolder() + File.separator + BINDING_ID + + File.separator; private final ChannelUID mapChannelUid; private ExpiringCache status; @@ -445,8 +448,7 @@ public void onMessageReceived(MiIoSendCommand response) { String mapresponse = response.getResult().getAsJsonArray().get(0).getAsString(); if (!mapresponse.contentEquals("retry") && !mapresponse.contentEquals(lastMap)) { lastMap = mapresponse; - scheduler.schedule(() -> updateState(CHANNEL_VACUUM_MAP, getMap(mapresponse)), 0, - TimeUnit.MILLISECONDS); + scheduler.submit(() -> updateState(CHANNEL_VACUUM_MAP, getMap(mapresponse))); } } break; @@ -459,8 +461,8 @@ public void onMessageReceived(MiIoSendCommand response) { } private State getMap(String map) { - final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class); - if (cloudConnector.isConnected()) { + final MiIoBindingConfiguration configuration = this.configuration; + if (configuration != null && cloudConnector.isConnected()) { try { final @Nullable RawType mapDl = cloudConnector.getMap(map, (configuration.cloudServer != null) ? configuration.cloudServer : ""); @@ -469,11 +471,9 @@ private State getMap(String map) { RRMapDraw rrMap = RRMapDraw.loadImage(new ByteArrayInputStream(mapData)); ByteArrayOutputStream baos = new ByteArrayOutputStream(); if (logger.isDebugEnabled()) { - String fn = ConfigConstants.getUserDataFolder() + File.separator + BINDING_ID + File.separator - + map; - CloudUtil.writeBytesToFileNio(mapData, - fn + (new SimpleDateFormat("yyyyMMdd-HHss")).format(new Date()) + ".rrmap"); - logger.debug("Mapdata saved to {}", fn + ".rrmap"); + final String mapPath = MAP_PATH + map + DATEFORMATTER.format(new Date()) + ".rrmap"; + CloudUtil.writeBytesToFileNio(mapData, mapPath); + logger.debug("Mapdata saved to {}", mapPath); } ImageIO.write(rrMap.getImage(MAP_SCALE), "jpg", baos); byte[] byteArray = baos.toByteArray(); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java index 98b87292f23df..9ee93d4e6d950 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java @@ -50,6 +50,10 @@ @NonNullByDefault public class RRMapDraw { + private static final int MAP_OUTSIDE = 0x00; + private static final int MAP_WALL = 0x01; + private static final int MAP_INSIDE = 0xFF; + private static final int MAP_SCAN = 0x07; private static final Color COLOR_MAP_INSIDE = new Color(32, 115, 185); private static final Color COLOR_MAP_OUTSIDE = new Color(19, 87, 148); private static final Color COLOR_MAP_WALL = new Color(100, 196, 254); @@ -80,7 +84,7 @@ public class RRMapDraw { ROOM11, ROOM12, ROOM13, ROOM14, ROOM15, ROOM16 }; private final @Nullable Bundle bundle = FrameworkUtil.getBundle(getClass()); private boolean multicolor = false; - private RRMapFileParser rmfp; + private final RRMapFileParser rmfp; private final Logger logger = LoggerFactory.getLogger(RRMapDraw.class); @@ -88,10 +92,6 @@ public RRMapDraw(RRMapFileParser rmfp) { this.rmfp = rmfp; } - public void setRRFileDecoder(RRMapFileParser rmfp) { - this.rmfp = rmfp; - } - public int getWidth() { return rmfp.getImgWidth(); } @@ -130,16 +130,16 @@ private void drawMap(Graphics2D g2d, float scale) { for (int x = 0; x < rmfp.getImgWidth() + 1; x++) { byte walltype = rmfp.getImage()[x + rmfp.getImgWidth() * y]; switch (walltype & 0xFF) { - case 0x00: + case MAP_OUTSIDE: g2d.setColor(COLOR_MAP_OUTSIDE); break; - case 0x01: + case MAP_WALL: g2d.setColor(COLOR_MAP_WALL); break; - case 0xFF: + case MAP_INSIDE: g2d.setColor(COLOR_MAP_INSIDE); break; - case 0x07: + case MAP_SCAN: g2d.setColor(COLOR_SCAN); break; default: @@ -319,30 +319,31 @@ private void drawOpenHabRocks(Graphics2D g2d, int width, int height, float scale try { if (image != null) { BufferedImage ohLogo = ImageIO.read(image); - textPos = (int) (ohLogo.getWidth() * scale / 2 + offset); + textPos = (int) (ohLogo.getWidth() * scale / 2 + offset * scale); AffineTransform at = new AffineTransform(); at.scale(scale / 2, scale / 2); AffineTransformOp scaleOp = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); - g2d.drawImage(ohLogo, scaleOp, offset, height - (int) (ohLogo.getHeight() * scale / 2) - offset); + g2d.drawImage(ohLogo, scaleOp, offset, + height - (int) (ohLogo.getHeight() * scale / 2) - (int) (offset * scale)); } else { logger.debug("Error loading image ohlogo.png: File not be found."); } } catch (IOException e) { logger.debug("Error loading image ohlogo.png:: {}", e.getMessage()); } - Font font = new Font("TimesRoman", Font.BOLD, 14); + Font font = new Font("Helvetica", Font.BOLD, 14); g2d.setFont(font); String message = "Openhab rocks your Xiaomi vacuum!"; FontMetrics fontMetrics = g2d.getFontMetrics(); int stringWidth = fontMetrics.stringWidth(message); if ((stringWidth + textPos) > rmfp.getImgWidth() * scale) { font = new Font("Helvetica ", Font.BOLD, - (int) Math.floor(14 * (rmfp.getImgWidth() * scale - textPos - offset) / stringWidth)); + (int) Math.floor(14 * (rmfp.getImgWidth() * scale - textPos - offset * scale) / stringWidth)); g2d.setFont(font); } int stringHeight = fontMetrics.getAscent(); g2d.setPaint(Color.white); - g2d.drawString(message, textPos, height - offset - stringHeight / 2); + g2d.drawString(message, textPos, height - offset * scale - stringHeight / 2); } private @Nullable URL getImageUrl(String image) { diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java index c78edecf3716f..13e8ff22d8621 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java @@ -17,6 +17,8 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -92,10 +94,8 @@ public class RRMapFileParser { public RRMapFileParser(byte[] raw) { boolean printBlockDetails = false; - int mapHeaderLength = getUInt16(raw, 0x02); int mapDataLength = getUInt32LE(raw, 0x04); - this.majorVersion = getUInt16(raw, 0x08); this.minorVersion = getUInt16(raw, 0x0A); this.mapIndex = getUInt32LE(raw, 0x0C); @@ -271,32 +271,32 @@ private int getUInt16(byte[] bytes, int pos) { @Override public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("RR Map:\tMajor Version: " + majorVersion + " Minor version: " + minorVersion + " Map Index: " - + mapIndex + " Map Sequence: " + mapSequence + " \r\n"); - sb.append("Image:\tsize: " + Integer.toString(imageSize) + "\ttop: " + Integer.toString(top) + "\tleft: " - + Integer.toString(left) + " height: " + Integer.toString(imgHeight) + " width: " - + Integer.toString(imgWidth) + "\r\n"); - sb.append( - "Charger pos:\tX: " + Float.toString(getChargerX()) + "\tY: " + Float.toString(getChargerY()) + "\r\n"); - sb.append("Robo pos:\tX: " + Float.toString(getRoboX()) + "\tY: " + Float.toString(getRoboY()) + " Angle: " - + Float.toString(getRoboA()) + "\r\n"); - sb.append("Goto:\tX: " + Float.toString(getGotoX()) + "\tY: " + Float.toString(getGotoY()) + "\r\n"); + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + pw.printf("RR Map:\tMajor Version: %d Minor version: %d Map Index: %d Map Sequence: %d\r\n", majorVersion, + minorVersion, mapIndex, mapSequence); + pw.printf("Image:\tsize: %9d\ttop: %9d\tleft: %9d height: %9d width: %9d\r\n", imageSize, top, left, imgHeight, + imgWidth); + pw.printf("Charger pos:\tX: %.1f\tY: %.1f\r\n", getChargerX(), getChargerY()); + pw.printf("Robo pos:\tX: %.1f\tY: %.1f\tAngle: %d\r\n", getRoboX(), getRoboY(), getRoboA()); + pw.printf("Goto:\tX: %.1f\tY: %.1f\r\n", getGotoX(), getGotoY()); for (Integer area : areas.keySet()) { - sb.append((area == NO_GO_AREAS ? "No Go zones:\t" : "MFBZS zones:\t") - + Integer.toString(areas.get(area).size()) + "\r\n"); + pw.print(area == NO_GO_AREAS ? "No Go zones:\t" : "MFBZS zones:\t"); + pw.printf("%d\r\n", areas.get(area).size()); } - sb.append("Walls:\t" + Integer.toString(walls.size()) + "\r\n"); - sb.append("Obstacles:\t" + Integer.toString(obstacles.size()) + "\r\n"); - sb.append("Blocks:\t" + Integer.toString(blocks.length) + "\r\n"); - sb.append("Paths:"); + pw.printf("Walls:\t%d\r\n", walls.size()); + pw.printf("Obstacles:\t%d\r\n", obstacles.size()); + pw.printf("Blocks:\t%d\r\n", blocks.length); + pw.print("Paths:"); for (Integer p : pathsDetails.keySet()) { - sb.append("\r\nPath type:\t" + Integer.toString(p)); + pw.printf("\r\nPath type:\t%d", p); for (String detail : pathsDetails.get(p).keySet()) { - sb.append(" " + detail + ": " + Integer.toString(pathsDetails.get(p).get(detail))); + pw.printf(" %s: %d", detail, pathsDetails.get(p).get(detail)); } } - return sb.toString(); + pw.println(); + pw.close(); + return sw.toString(); } /** diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java index f686b4904cfc4..07f38dd31eb13 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/transport/MiIoAsyncCommunication.java @@ -146,12 +146,15 @@ public int queueCommand(String command, String params) MiIoSendCommand sendCmd = new MiIoSendCommand(cmdId, MiIoCommand.getCommand(command), fullCommand.toString()); concurrentLinkedQueue.add(sendCmd); - String tokenText = Utils.getHex(token); // Obfuscate part of the token to allow sharing of the logfiles - tokenText = tokenText.substring(0, 8) - .concat((tokenText.length() > 8) ? tokenText.substring(8, 24).replaceAll(".", "X") : "") - .concat((tokenText.length() > 24) ? tokenText.substring(24) : ""); - logger.debug("Command added to Queue {} -> {} (Device: {} token: {} Queue: {})", fullCommand.toString(), ip, - Utils.getHex(deviceId), tokenText, concurrentLinkedQueue.size()); + if (logger.isDebugEnabled()) { + String tokenText = Utils.getHex(token); // Obfuscate part of the token to allow sharing of the logfiles + tokenText = ((tokenText.length() < 8) ? tokenText : tokenText.substring(0, 8)) + .concat((tokenText.length() > 24) ? tokenText.substring(8, 24).replaceAll(".", "X") + : tokenText.substring(8).replaceAll(".", "X")) + .concat(tokenText.substring(24)); + logger.debug("Command added to Queue {} -> {} (Device: {} token: {} Queue: {})", fullCommand.toString(), + ip, Utils.getHex(deviceId), tokenText, concurrentLinkedQueue.size()); + } if (needPing) { sendPing(ip); } @@ -310,7 +313,7 @@ private void pingSuccess() { if (!connected) { connected = true; status = ThingStatusDetail.NONE; - updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE); } else { if (ThingStatusDetail.CONFIGURATION_ERROR.equals(status)) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR); From 98bbf1aeaf18438efcdd745fe15f67d20ca8dc13 Mon Sep 17 00:00:00 2001 From: Marcel Verpaalen Date: Mon, 6 Apr 2020 00:14:21 +0200 Subject: [PATCH 06/10] [miio] update readme & small cleanup Signed-off-by: Marcel Verpaalen --- .../org.openhab.binding.miio/README.base.md | 4 +- bundles/org.openhab.binding.miio/README.md | 59 +++++++++++++++++-- .../miio/internal/cloud/CloudConnector.java | 2 +- .../miio/internal/cloud/MiCloudConnector.java | 32 +++++----- 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/bundles/org.openhab.binding.miio/README.base.md b/bundles/org.openhab.binding.miio/README.base.md index d07fc7825186f..d0d7160b070d8 100644 --- a/bundles/org.openhab.binding.miio/README.base.md +++ b/bundles/org.openhab.binding.miio/README.base.md @@ -75,7 +75,7 @@ However, for devices that are unsupported, you may override the value and try to # Advanced: Unsupported devices Newer devices may not yet be supported. -However, many devices share large similarties with existing devices. +However, many devices share large similarities with existing devices. The binding allows to try/test if your new device is working with database files of older devices as well. For this, first remove your unsupported thing. Manually add a miio:basic thing. Besides the regular configuration (like ip address, token) the modelId needs to be provided. @@ -86,7 +86,7 @@ Look at the openhab forum, or the openhab github repository for the modelId of s 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. -The config/misc/miio (e.g. in Linux `/opt/openhab2/config/misc/miio/`) is scanned for database files and will be used for your devices. +The conf/misc/miio (e.g. in Linux `/opt/openhab2/conf/misc/miio/`) is scanned for database files and will be used for your devices. 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. diff --git a/bundles/org.openhab.binding.miio/README.md b/bundles/org.openhab.binding.miio/README.md index f7d328270760a..7eac26491e9f6 100644 --- a/bundles/org.openhab.binding.miio/README.md +++ b/bundles/org.openhab.binding.miio/README.md @@ -16,6 +16,58 @@ The following things types are available: | miio:basic | For several basic devices like yeelights, airpurifiers. Channels and commands are determined by database configuration | | miio:unsupported | For experimenting with other devices which use the Mi IO protocol | +# Discovery + +The binding has 2 methods for discovering devices. Depending on your network setup and the device model, your device may be discovered by one or both methods. If both methods discover your device, 2 discovery results may be in your inbox for the same device. + +The mDNS discovery method will discover your device type, but will not discover a (required) token. +The basic discovery will not discovery the type, but will discover a token for models that support it. +Accept only one of the 2 discovery results, the alternate one can further be ignored. + +## Tokens + +The binding needs a token from the Xiaomi Mi Device in order to be able to control it. +The binding can retrieve the needed tokens from the Xiaomi cloud. Go to the binding config page and enter your cloud username and password. The server(s) to which your devices are connected need to be entered as well. Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. + +## Tokens without cloud access + +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. + +The easiest way to obtain tokens is to browse through log files of the Mi Home app version 5.4.49 for Android. +It seems that version was released with debug messages turned on by mistake. +An APK file with the old version can be easily found using one of the popular web search engines. +After downgrading use a file browser to navigate to directory SmartHome/logs/plug_DeviceManager, then open the most recent file and search for the token. When finished, use Google Play to get the most recent version back. + +For iPhone, use an un-encrypted iTunes-Backup and unpack it and use a sqlite tool to view the files in it: +Then search in "RAW, com.xiaomi.home," for "USERID_mihome.sqlite" and look for the 32-digit-token or 96 digit encrypted token. + +Note. The Xiaomi devices change the token when inclusion is done. Hence if you get your token after reset and than include it with the Mi Home app, the token will change. + +## Binding Configuration + +No binding configuration is required. However to enable cloud functionality enter Xiaomi username, password and server(s) + +## Thing Configuration + +Each Xiaomi device (thing) needs the IP address and token configured to be able to communicate. See discovery for details. +Optional configuration is the refresh interval and the deviceID. Note that the deviceID is automatically retrieved when it is left blank. +The configuration for model is automatically retrieved from the device in normal operation. +However, for devices that are unsupported, you may override the value and try to use a model string from a similar device to experimentally use your device with the binding. + +| Parameter | Type | Required | Description | +|-----------------|---------|----------|-------------------------------------------------------------------| +| host | text | true | Device IP address | +| token | text | true | Token for communication (in Hex) | +| deviceId | text | true | Device ID number for communication (in Hex) | +| model | text | false | Device model string, used to determine the subtype | +| refreshInterval | integer | false | Refresh interval for refreshing the data in seconds. (0=disabled) | +| timeout | integer | false | Timeout time in milliseconds | + +### Example Thing file + +`Thing miio:basic:light "My Light" [ host="192.168.x.x", token="put here your token", deviceId="0326xxxx" ]` + ## Mi IO Devices | Device | ThingType | Device Model | Supported | Remark | @@ -168,7 +220,7 @@ The following things types are available: # Advanced: Unsupported devices Newer devices may not yet be supported. -However, many devices share large similarties with existing devices. +However, many devices share large similarities with existing devices. The binding allows to try/test if your new device is working with database files of older devices as well. For this, first remove your unsupported thing. Manually add a miio:basic thing. Besides the regular configuration (like ip address, token) the modelId needs to be provided. @@ -179,7 +231,7 @@ Look at the openhab forum, or the openhab github repository for the modelId of s 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. -The config/misc/miio (e.g. in Linux `/opt/openhab2/config/misc/miio/`) is scanned for database files and will be used for your devices. +The conf/misc/miio (e.g. in Linux `/opt/openhab2/conf/misc/miio/`) is scanned for database files and will be used for your devices. 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. @@ -279,7 +331,6 @@ e.g. `smarthome:send actionCommand 'upd_timer["1498595904821", "on"]'` would ena | Channel | Type | Description | |------------------|---------|-------------------------------------| | power | Switch | Power | -| humidifierMode | String | Mode | | humidifierMode | String | Humidifier Mode | | humidity | Number | Humidity | | setHumidity | Number | Humidity Set | @@ -1580,7 +1631,7 @@ note: Autogenerated example. Replace the id (humidifier) in the channel with you ```java Group G_humidifier "Mi Air Humidifier 2" Switch power "Power" (G_humidifier) {channel="miio:basic:humidifier:power"} -String humidifierMode "Mode" (G_humidifier) {channel="miio:basic:humidifier:humidifierMode"} +String humidifierMode "Humidifier Mode" (G_humidifier) {channel="miio:basic:humidifier:humidifierMode"} Number humidity "Humidity" (G_humidifier) {channel="miio:basic:humidifier:humidity"} Number setHumidity "Humidity Set" (G_humidifier) {channel="miio:basic:humidifier:setHumidity"} Number bright "LED Brightness" (G_humidifier) {channel="miio:basic:humidifier:bright"} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java index 7c24254daa724..931fca59e7b3c 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java @@ -75,7 +75,7 @@ public class CloudConnector { deviceList.clear(); for (String server : country.split(",")) { try { - cl.getDevices(server); + deviceList.addAll(cl.getDevices(server)); } catch (JsonParseException e) { logger.debug("Parsing error getting devices: {}", e.getMessage()); } diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java index 64c6fe2202032..96c68108747a4 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -298,14 +298,6 @@ private void addCookie(CookieStore cookieStore, String name, String value, Strin cookieStore.add(URI.create("https://" + domain), cookie); } - private void removeCookies(CookieStore cookieStore, String domain) { - URI uri = URI.create(domain); - final List cookies = cookieStore.get(uri); - for (HttpCookie cookie : cookies) { - cookieStore.remove(uri, cookie); - } - } - public synchronized boolean login() { if (!checkCredentials()) { return false; @@ -325,6 +317,11 @@ public synchronized boolean login() { logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage()); loginFailedCounter++; serviceToken = ""; + if (loginFailedCounter > 10) { + logger.info("Repeated errors logging on to Xiaomi cloud. Cleaning stored cookies"); + dumpCookies(".xiaomi.com", true); + dumpCookies(".mi.com", true); + } return false; } return true; @@ -437,8 +434,9 @@ private String loginStep2(String sign) logger.trace("Xiaomi login passToken = {}", passToken); logger.trace("Xiaomi login location = {}", location); logger.trace("Xiaomi login code = {}", code); - - dumpCookies(url); + if (logger.isTraceEnabled()) { + dumpCookies(url, false); + } if (!location.isEmpty()) { return location; } else { @@ -457,8 +455,9 @@ private ContentResponse loginStep3(String location) responseStep3 = request.send(); logger.trace("Xiaomi login step 3 content = {}", responseStep3.getContentAsString()); logger.trace("Xiaomi login step 3 response = {}", responseStep3); - - dumpCookies(location); + if (logger.isTraceEnabled()) { + dumpCookies(location, false); + } URI uri = URI.create("http://sts.api.io.mi.com"); String serviceToken = extractServiceToken(uri); if (!serviceToken.isEmpty()) { @@ -467,7 +466,7 @@ private ContentResponse loginStep3(String location) return responseStep3; } - private void dumpCookies(String url) { + private void dumpCookies(String url, boolean delete) { if (logger.isTraceEnabled()) { try { URI uri = URI.create(url); @@ -476,8 +475,11 @@ private void dumpCookies(String url) { CookieStore cs = httpClient.getCookieStore(); List cookies = cs.get(uri); for (HttpCookie cookie : cookies) { - logger.trace("Cookie ({}) : {} --> {} (path: {})", cookie.getDomain(), cookie.getName(), - cookie.getValue(), cookie.getPath()); + logger.trace("Cookie ({}) : {} --> {} (path: {}. Removed: {})", cookie.getDomain(), + cookie.getName(), cookie.getValue(), cookie.getPath(), delete); + if (delete) { + cs.remove(uri, cookie); + } } } else { logger.trace("Could not create URI from {}", url); From fdf64d48c30163727a4ae882ecebdc43fbe21ab6 Mon Sep 17 00:00:00 2001 From: Marcel Verpaalen Date: Mon, 6 Apr 2020 01:24:23 +0200 Subject: [PATCH 07/10] [miio] small fix Signed-off-by: Marcel Verpaalen --- .../openhab/binding/miio/internal/cloud/MiCloudConnector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java index 96c68108747a4..b8dc384f456ca 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -485,7 +485,7 @@ private void dumpCookies(String url, boolean delete) { logger.trace("Could not create URI from {}", url); } } catch (IllegalArgumentException | NullPointerException e) { - logger.trace("Error dumping cookies from {}: ", url, e.getMessage(), e); + logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e); } } } From c6dec0f194d2428d11d5081cb1f01e4ec2b23ddb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2020 11:23:29 +0200 Subject: [PATCH 08/10] Apply suggestions from code review Signed-off-by: Marcel Verpaalen marcel@verpaalen.com Co-Authored-By: cpmeister --- bundles/org.openhab.binding.miio/README.base.md | 2 +- .../openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.miio/README.base.md b/bundles/org.openhab.binding.miio/README.base.md index d0d7160b070d8..5bbd61425ae58 100644 --- a/bundles/org.openhab.binding.miio/README.base.md +++ b/bundles/org.openhab.binding.miio/README.base.md @@ -46,7 +46,7 @@ Note. The Xiaomi devices change the token when inclusion is done. Hence if you g ## Binding Configuration -No binding configuration is required. However to enable cloud functionality enter Xiaomi username, password and server(s) +No binding configuration is required. However to enable cloud functionality enter your Xiaomi username, password and server(s) ## Thing Configuration diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java index 8c2592bce2620..2f3647c5803a3 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudDeviceListDTO.java @@ -28,7 +28,7 @@ public class CloudDeviceListDTO { @Expose private List cloudDevices = null; - public java.util.List getCloudDevices() { + public List getCloudDevices() { return cloudDevices; } } From 2b100a328dc9c417b3c0cbe7a46e39842e874bb7 Mon Sep 17 00:00:00 2001 From: Marcel Verpaalen Date: Mon, 6 Apr 2020 20:16:34 +0200 Subject: [PATCH 09/10] [miio] updates based on feedback Signed-off-by: Marcel Verpaalen --- .../org.openhab.binding.miio/README.base.md | 5 ++- bundles/org.openhab.binding.miio/README.md | 7 ++-- .../miio/internal/MiIoHandlerFactory.java | 3 +- .../miio/internal/cloud/CloudConnector.java | 31 ++++++++-------- .../miio/internal/cloud/CloudLoginDTO.java | 2 +- .../miio/internal/cloud/CloudUtil.java | 36 +++++++++---------- .../miio/internal/cloud/MiCloudConnector.java | 2 +- .../internal/handler/MiIoVacuumHandler.java | 10 +++--- 8 files changed, 52 insertions(+), 44 deletions(-) diff --git a/bundles/org.openhab.binding.miio/README.base.md b/bundles/org.openhab.binding.miio/README.base.md index 5bbd61425ae58..221ffeee99a7b 100644 --- a/bundles/org.openhab.binding.miio/README.base.md +++ b/bundles/org.openhab.binding.miio/README.base.md @@ -27,7 +27,10 @@ Accept only one of the 2 discovery results, the alternate one can further be ign ## Tokens The binding needs a token from the Xiaomi Mi Device in order to be able to control it. -The binding can retrieve the needed tokens from the Xiaomi cloud. Go to the binding config page and enter your cloud username and password. The server(s) to which your devices are connected need to be entered as well. Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. +The binding can retrieve the needed tokens from the Xiaomi cloud. +Go to the binding config page and enter your cloud username and password. +The server(s) to which your devices are connected need to be entered as well. +Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. ## Tokens without cloud access diff --git a/bundles/org.openhab.binding.miio/README.md b/bundles/org.openhab.binding.miio/README.md index 7eac26491e9f6..d7fb2fd12d703 100644 --- a/bundles/org.openhab.binding.miio/README.md +++ b/bundles/org.openhab.binding.miio/README.md @@ -27,7 +27,10 @@ Accept only one of the 2 discovery results, the alternate one can further be ign ## Tokens The binding needs a token from the Xiaomi Mi Device in order to be able to control it. -The binding can retrieve the needed tokens from the Xiaomi cloud. Go to the binding config page and enter your cloud username and password. The server(s) to which your devices are connected need to be entered as well. Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. +The binding can retrieve the needed tokens from the Xiaomi cloud. +Go to the binding config page and enter your cloud username and password. +The server(s) to which your devices are connected need to be entered as well. +Use the one of the regional servers: ru,us,tw,sg,cn,de. Multiple servers can be separated with comma, or leave blank to test all known servers. ## Tokens without cloud access @@ -46,7 +49,7 @@ Note. The Xiaomi devices change the token when inclusion is done. Hence if you g ## Binding Configuration -No binding configuration is required. However to enable cloud functionality enter Xiaomi username, password and server(s) +No binding configuration is required. However to enable cloud functionality enter your Xiaomi username, password and server(s) ## Thing Configuration diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java index eb587e2a7ab4c..7fe26799c5bc8 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoHandlerFactory.java @@ -69,7 +69,8 @@ protected void activate(ComponentContext componentContext) { String password = (String) properties.get("password"); @Nullable String country = (String) properties.get("country"); - scheduler.submit(() -> cloudConnector.setCredentials(username, password, country)); + cloudConnector.setCredentials(username, password, country); + scheduler.submit(() -> cloudConnector.isConnected()); } @Override diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java index 931fca59e7b3c..346a1f9e3666f 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java @@ -43,12 +43,16 @@ @NonNullByDefault public class CloudConnector { - protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(60); - private static final int FAILED = -1; - private static final int STARTING = 0; - private static final int REFRESHING = 1; - private static final int AVAILABLE = 2; - private volatile int deviceListState = STARTING; + private static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(60); + + private static enum DeviceListState { + FAILED, + STARTING, + REFRESHING, + AVAILABLE, + } + + private volatile DeviceListState deviceListState = DeviceListState.STARTING; private String username = ""; private String password = ""; @@ -64,14 +68,14 @@ public class CloudConnector { }); private ExpiringCache refreshDeviceList = new ExpiringCache(CACHE_EXPIRY, () -> { - if (deviceListState == FAILED && !isConnected()) { + if (deviceListState == DeviceListState.FAILED && !isConnected()) { return ("Could not connect to Xiaomi cloud"); } final @Nullable MiCloudConnector cl = this.cloudConnector; if (cl == null) { return ("Could not connect to Xiaomi cloud"); } - deviceListState = REFRESHING; + deviceListState = DeviceListState.REFRESHING; deviceList.clear(); for (String server : country.split(",")) { try { @@ -80,7 +84,7 @@ public class CloudConnector { logger.debug("Parsing error getting devices: {}", e.getMessage()); } } - deviceListState = AVAILABLE; + deviceListState = DeviceListState.AVAILABLE; return "done";// deviceList; }); @@ -107,7 +111,7 @@ public boolean isConnected() { if (c != null && c.booleanValue()) { return true; } - deviceListState = FAILED; + deviceListState = DeviceListState.FAILED; return false; } @@ -150,7 +154,6 @@ public void setCredentials(@Nullable String username, @Nullable String password, if (username != null && password != null) { this.username = username; this.password = password; - logon(); } } @@ -168,11 +171,11 @@ private boolean logon() { if (connected) { getDevicesList(); } else { - deviceListState = FAILED; + deviceListState = DeviceListState.FAILED; } } catch (MiCloudException e) { connected = false; - deviceListState = FAILED; + deviceListState = DeviceListState.FAILED; logger.debug("Xiaomi cloud login failed: {}", e.getMessage()); } return connected; @@ -185,7 +188,7 @@ public List getDevicesList() { public @Nullable CloudDeviceDTO getDeviceInfo(String id) { getDevicesList(); - if (deviceListState < AVAILABLE) { + if (deviceListState != DeviceListState.AVAILABLE) { return null; } String did = Long.toString(Long.parseUnsignedLong(id, 16)); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java index ba5958f9addf6..8c7c223c49c91 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudLoginDTO.java @@ -123,4 +123,4 @@ public Object getCaptchaUrl() { public void setCaptchaUrl(Object captchaUrl) { this.captchaUrl = captchaUrl; } -} \ No newline at end of file +} diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java index 074adaab400a2..32449ea7f94c3 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java @@ -18,6 +18,7 @@ import java.io.FileWriter; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -36,8 +37,6 @@ import org.openhab.binding.miio.internal.MiIoCryptoException; import org.slf4j.Logger; -import com.google.gson.JsonElement; - /** * The {@link CloudUtil} class is used for supporting functions for Xiaomi cloud access * @@ -47,24 +46,22 @@ public class CloudUtil { private static final Random RANDOM = new Random(); + private static final String DB_FOLDER_NAME = ConfigConstants.getUserDataFolder() + File.separator + + MiIoBindingConstants.BINDING_ID; - public static String getElementString(JsonElement jsonElement, String element, Logger logger) { - String value = ""; - try { - value = jsonElement.getAsJsonObject().get(element).getAsString(); - } catch (IllegalStateException | ClassCastException e) { - logger.debug("Json Element {} expected but missing", element); - } - return value; - } - - public static void saveFile(String data, String country, Logger logger) { - String dbFolderName = ConfigConstants.getUserDataFolder() + File.separator + MiIoBindingConstants.BINDING_ID; - File folder = new File(dbFolderName); + /** + * Saves the Xiaomi cloud device info with tokens to file + * + * @param data file content + * @param country county server + * @param logger + */ + public static void saveDeviceInfoFile(String data, String country, Logger logger) { + File folder = new File(DB_FOLDER_NAME); if (!folder.exists()) { folder.mkdirs(); } - File dataFile = new File(dbFolderName + File.separator + "miioTokens-" + country + ".json"); + File dataFile = new File(DB_FOLDER_NAME + File.separator + "miioTokens-" + country + ".json"); try (FileWriter writer = new FileWriter(dataFile)) { writer.write(data); logger.debug("Devices token info saved to {}", dataFile.getAbsolutePath()); @@ -115,7 +112,8 @@ public static String generateSignature(@Nullable String requestUrl, @Nullable St } sb.append(s); } - return CloudCrypto.hMacSha256Encode(Base64.getDecoder().decode(signedNonce), sb.toString().getBytes()); + return CloudCrypto.hMacSha256Encode(Base64.getDecoder().decode(signedNonce), + sb.toString().getBytes(StandardCharsets.UTF_8)); } public static String generateNonce(long milli) throws IOException { @@ -128,8 +126,8 @@ public static String generateNonce(long milli) throws IOException { } public static String signedNonce(String ssecret, String nonce) throws IOException, MiIoCryptoException { - byte[] byteArrayS = Base64.getDecoder().decode(ssecret.getBytes()); - byte[] byteArrayN = Base64.getDecoder().decode(nonce.getBytes()); + byte[] byteArrayS = Base64.getDecoder().decode(ssecret.getBytes(StandardCharsets.UTF_8)); + byte[] byteArrayN = Base64.getDecoder().decode(nonce.getBytes(StandardCharsets.UTF_8)); ByteArrayOutputStream output = new ByteArrayOutputStream(); output.write(byteArrayS); output.write(byteArrayN); diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java index b8dc384f456ca..1b41b669405a4 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -219,7 +219,7 @@ public String getDeviceString(String country) { resp = request(url, map); logger.trace("Get devices response: {}", resp); if (resp.length() > 2) { - CloudUtil.saveFile(resp, country, logger); + CloudUtil.saveDeviceInfoFile(resp, country, logger); return resp; } } catch (MiCloudException e) { diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java index ba0deeea5cce9..6636e64748678 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoVacuumHandler.java @@ -81,7 +81,7 @@ public class MiIoVacuumHandler extends MiIoAbstractHandler { private ExpiringCache consumables; private ExpiringCache dnd; private ExpiringCache history; - private int inCleaning; + private int stateId; private ExpiringCache map; private String lastHistoryId = ""; private String lastMap = ""; @@ -229,12 +229,12 @@ private boolean updateVacuumStatus(JsonObject statusData) { int fanLevel = statusData.get("fan_power").getAsInt(); updateState(CHANNEL_FAN_POWER, new DecimalType(fanLevel)); updateState(CHANNEL_FAN_CONTROL, new DecimalType(FanModeType.getType(fanLevel).getId())); - inCleaning = statusData.get("in_cleaning").getAsInt(); - updateState(CHANNEL_IN_CLEANING, new DecimalType(inCleaning)); + updateState(CHANNEL_IN_CLEANING, new DecimalType(statusData.get("in_cleaning").getAsInt())); updateState(CHANNEL_MAP_PRESENT, new DecimalType(statusData.get("map_present").getAsBigDecimal())); StatusType state = StatusType.getType(statusData.get("state").getAsInt()); updateState(CHANNEL_STATE, new StringType(state.getDescription())); - updateState(CHANNEL_STATE_ID, new DecimalType(statusData.get("state").getAsInt())); + stateId = statusData.get("state").getAsInt(); + updateState(CHANNEL_STATE_ID, new DecimalType(stateId)); State vacuum = OnOffType.OFF; String control; switch (state) { @@ -391,7 +391,7 @@ protected synchronized void updateData() { status.getValue(); refreshNetwork(); consumables.getValue(); - if (lastMap.isEmpty() || inCleaning != 0) { + if (lastMap.isEmpty() || stateId != 8) { if (isLinked(mapChannelUid)) { map.getValue(); } From da9657eb1f56b498ecd7b364701c52b7cd8aa2ad Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2020 08:18:37 +0200 Subject: [PATCH 10/10] Update bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java Signed-off-by: Marcel Verpaalen Co-Authored-By: cpmeister --- .../java/org/openhab/binding/miio/internal/cloud/CloudUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java index 32449ea7f94c3..f20ecb9154d07 100644 --- a/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java @@ -61,7 +61,7 @@ public static void saveDeviceInfoFile(String data, String country, Logger logger if (!folder.exists()) { folder.mkdirs(); } - File dataFile = new File(DB_FOLDER_NAME + File.separator + "miioTokens-" + country + ".json"); + File dataFile = new File(folder, "miioTokens-" + country + ".json"); try (FileWriter writer = new FileWriter(dataFile)) { writer.write(data); logger.debug("Devices token info saved to {}", dataFile.getAbsolutePath());