diff --git a/bundles/org.openhab.binding.miio/README.base.md b/bundles/org.openhab.binding.miio/README.base.md index d6fc70ac6bd25..221ffeee99a7b 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,13 @@ 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 +49,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 your Xiaomi username, password and server(s) ## Thing Configuration @@ -82,11 +67,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 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. +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 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. + ## Channels Depending on the device, different channels are available. @@ -116,6 +122,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 +148,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. !!!itemFileExamples diff --git a/bundles/org.openhab.binding.miio/README.md b/bundles/org.openhab.binding.miio/README.md index 6332da95a8f55..d7fb2fd12d703 100644 --- a/bundles/org.openhab.binding.miio/README.md +++ b/bundles/org.openhab.binding.miio/README.md @@ -16,6 +16,61 @@ 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 your 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 +154,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 | | @@ -155,7 +223,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. @@ -166,59 +234,10 @@ 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. -# 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. @@ -1480,6 +1499,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" } @@ -1505,8 +1525,19 @@ 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 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/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/MiIoDevices.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/MiIoDevices.java index 4e01440ea7a9e..b10225a8b45f1 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..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 @@ -14,18 +14,24 @@ import static org.openhab.binding.miio.internal.MiIoBindingConstants.*; +import java.util.Dictionary; +import java.util.concurrent.ScheduledExecutorService; + 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; import org.eclipse.smarthome.core.thing.binding.ThingHandler; import org.eclipse.smarthome.core.thing.binding.ThingHandlerFactory; 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; @@ -39,12 +45,32 @@ @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; @Activate - public MiIoHandlerFactory(@Reference MiIoDatabaseWatchService miIoDatabaseWatchService) { + public MiIoHandlerFactory(@Reference MiIoDatabaseWatchService miIoDatabaseWatchService, + @Reference CloudConnector cloudConnector) { this.miIoDatabaseWatchService = miIoDatabaseWatchService; + this.cloudConnector = cloudConnector; + } + + @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.setCredentials(username, password, country); + scheduler.submit(() -> cloudConnector.isConnected()); } @Override @@ -62,7 +88,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 new file mode 100644 index 0000000000000..346a1f9e3666f --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudConnector.java @@ -0,0 +1,215 @@ +/** + * 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 static org.openhab.binding.miio.internal.MiIoBindingConstants.BINDING_ID; + +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.core.library.types.RawType; +import org.eclipse.smarthome.io.net.http.HttpClientFactory; +import org.eclipse.smarthome.io.net.http.HttpUtil; +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; + +import com.google.gson.JsonParseException; + +/** + * The {@link CloudConnector} is responsible for connecting OH to the Xiaomi cloud communication. + * + * @author Marcel Verpaalen - Initial contribution + */ +@Component(service = CloudConnector.class) +@NonNullByDefault +public class CloudConnector { + + 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 = ""; + private String country = "ru,us,tw,sg,cn,de"; + 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 ExpiringCache logonCache = new ExpiringCache(CACHE_EXPIRY, () -> { + return logon(); + }); + + private ExpiringCache refreshDeviceList = new ExpiringCache(CACHE_EXPIRY, () -> { + 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 = DeviceListState.REFRESHING; + deviceList.clear(); + for (String server : country.split(",")) { + try { + deviceList.addAll(cl.getDevices(server)); + } catch (JsonParseException e) { + logger.debug("Parsing error getting devices: {}", e.getMessage()); + } + } + deviceListState = DeviceListState.AVAILABLE; + return "done";// deviceList; + }); + + @Activate + public CloudConnector(@Reference HttpClientFactory httpClientFactory) { + this.httpClient = httpClientFactory.createHttpClient(BINDING_ID); + } + + @Deactivate + public void dispose() { + final MiCloudConnector cl = cloudConnector; + if (cl != null) { + cl.stopClient(); + } + cloudConnector = null; + } + + 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 = DeviceListState.FAILED; + return false; + } + + 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 = ""; + final @Nullable MiCloudConnector cl = this.cloudConnector; + if (cl == null || !isConnected()) { + throw new MiCloudException("Cannot execute request. Cloudservice not available"); + } + if (country.isEmpty()) { + 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); + } + @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) { + if (country != null) { + this.country = country; + } + if (username != null && password != null) { + this.username = username; + this.password = password; + } + } + + private boolean logon() { + if (username.isEmpty() || password.isEmpty()) { + logger.info("No Xiaomi cloud credentials. Cloud connectivity diabled"); + logger.debug("Logon details: username: '{}', pass: '{}', country: '{}'", username, + password.replaceAll(".", "*"), country); + return connected; + } + try { + final MiCloudConnector cl = new MiCloudConnector(username, password, httpClient); + this.cloudConnector = cl; + connected = cl.login(); + if (connected) { + getDevicesList(); + } else { + deviceListState = DeviceListState.FAILED; + } + } catch (MiCloudException e) { + connected = false; + deviceListState = DeviceListState.FAILED; + logger.debug("Xiaomi cloud login failed: {}", e.getMessage()); + } + return connected; + } + + public List getDevicesList() { + refreshDeviceList.getValue(); + return deviceList; + } + + public @Nullable CloudDeviceDTO getDeviceInfo(String id) { + getDevicesList(); + if (deviceListState != DeviceListState.AVAILABLE) { + return null; + } + String did = Long.toString(Long.parseUnsignedLong(id, 16)); + List devicedata = new ArrayList<>(); + for (CloudDeviceDTO deviceDetails : deviceList) { + if (deviceDetails.getDid().contentEquals(did)) { + devicedata.add(deviceDetails); + } + } + 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 devicedata.get(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 new file mode 100644 index 0000000000000..769747b45997d --- /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(), e); + } + } + + /** + * 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(), e); + } + } +} 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..2f3647c5803a3 --- /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 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..8c7c223c49c91 --- /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; + } +} 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..f20ecb9154d07 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/CloudUtil.java @@ -0,0 +1,141 @@ +/** + * 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.charset.StandardCharsets; +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; + +/** + * 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(); + private static final String DB_FOLDER_NAME = ConfigConstants.getUserDataFolder() + File.separator + + MiIoBindingConstants.BINDING_ID; + + /** + * 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(folder, "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(StandardCharsets.UTF_8)); + } + + 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(StandardCharsets.UTF_8)); + byte[] byteArrayN = Base64.getDecoder().decode(nonce.getBytes(StandardCharsets.UTF_8)); + 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..1b41b669405a4 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudConnector.java @@ -0,0 +1,510 @@ +/** + * 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.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; + +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.Gson; +import com.google.gson.GsonBuilder; +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 static Locale locale = Locale.getDefault(); + 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; + private String password; + private String userId = ""; + private String serviceToken = ""; + private String ssecurity = ""; + private 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(); + 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"); + addCookie(cookieStore, "deviceId", this.clientId, "xiaomi.com"); + } catch (Exception e) { + throw new MiCloudException("No http client cannot be started: " + e.getMessage(), e); + } + } + } + + public void stopClient() { + try { + this.httpClient.stop(); + } catch (Exception e) { + logger.debug("Error stopping httpclient :{}", e.getMessage(), e); + } + } + + 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 + "\"]}"); + final String response = request(url, map); + logger.debug("response: {}", response); + return response; + } + + 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}"); + String resp; + try { + resp = request(url, map); + logger.trace("Get devices response: {}", resp); + if (resp.length() > 2) { + CloudUtil.saveDeviceInfoFile(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"); + } + startClient(); + 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", locale.toString())); + 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")); + + 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); + + 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(); + if (response.getStatus() == HttpStatus.FORBIDDEN_403) { + this.serviceToken = ""; + } + return response.getContentAsString(); + } catch (HttpResponseException e) { + serviceToken = ""; + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (InterruptedException | TimeoutException | ExecutionException | IOException e) { + logger.debug("Error while executing request to {} :{}", url, e.getMessage()); + } catch (MiIoCryptoException e) { + logger.debug("Error while decrypting response of 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); + } + + 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 = ""; + 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; + } + + protected boolean loginRequest() throws MiCloudException { + try { + startClient(); + String sign = loginStep1(); + 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()) { + 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(), e); + } catch (MiIoCryptoException e) { + 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(), e); + } + } + + 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)); + 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); + } + } + + 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", Utils.getHex(MiIoCrypto.md5(password.getBytes()))); + fields.put("callback", "https://sts.api.io.mi.com/sts"); + fields.put("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue"); + fields.put("user", username); + if (!sign.isEmpty()) { + fields.put("_sign", sign); + } + fields.put("_json", "true"); + + request.content(new FormContentProvider(fields)); + responseStep2 = request.send(); + + final String content2 = responseStep2.getContentAsString(); + logger.trace("Xiaomi login step 2 response = {}", responseStep2); + logger.trace("Xiaomi login step 2 content = {}", content2); + + JsonElement resp2 = new JsonParser().parse(parseJson(content2)); + 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); + logger.trace("Xiaomi login cUserId = {}", cUserId); + logger.trace("Xiaomi login passToken = {}", passToken); + logger.trace("Xiaomi login location = {}", location); + logger.trace("Xiaomi login code = {}", code); + if (logger.isTraceEnabled()) { + dumpCookies(url, false); + } + if (!location.isEmpty()) { + return location; + } else { + throw new MiCloudException("Error getting logon location URL. Return code: " + code); + } + } + + private ContentResponse loginStep3(String location) + throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException { + 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); + if (logger.isTraceEnabled()) { + dumpCookies(location, false); + } + 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, boolean delete) { + 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: {}. 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); + } + } catch (IllegalArgumentException | NullPointerException e) { + logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e); + } + } + } + + 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..8436c1b51456d --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/cloud/MiCloudException.java @@ -0,0 +1,40 @@ +/** + * 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 { + /** + * required variable to avoid IncorrectMultilineIndexException warning + */ + private static final long serialVersionUID = -1280858607995252321L; + + public MiCloudException() { + super(); + } + + public MiCloudException(String message) { + super(message); + } + + 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 49a260d407662..6ecf10dec66c8 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,7 +35,11 @@ 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.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; @@ -58,12 +62,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 @@ -117,7 +124,7 @@ protected void startScan() { private void discover() { startReceiverThreat(); responseIps = new HashSet<>(); - Set broadcastAddresses = new HashSet<>(); + HashSet broadcastAddresses = new HashSet<>(); broadcastAddresses.add("224.0.0.1"); broadcastAddresses.add("224.0.0.50"); broadcastAddresses.addAll(NetUtil.getAllBroadcastAddresses()); @@ -132,6 +139,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.isConnected()) { + cloudConnector.getDevicesList(); + 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); 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 +165,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/MiIoAbstractHandler.java b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/handler/MiIoAbstractHandler.java index 4cb06d2bebf69..9d5a6f9f68d7b 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,13 +63,13 @@ 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; protected JsonParser parser; protected byte[] token = new byte[0]; + protected @Nullable MiIoBindingConfiguration configuration; protected @Nullable MiIoAsyncCommunication miioCom; protected int lastId; @@ -101,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 58ad896e3822b..33213cbbfc032 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()) { + if (!hasConnection() || skipUpdate() || miioCom == null) { return; } - if (miioCom == null || !initializeData()) { - return; - } - try { - miioCom.startReceiver(); - miioCom.sendPing(configuration.host); - } catch (Exception e) { - // ignore - } checkChannelStructure(); if (!isIdentified) { miioCom.queueCommand(MiIoCommand.MIIO_INFO); @@ -252,26 +242,14 @@ 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. */ 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"); @@ -332,10 +310,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 5424dee610968..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 @@ -14,16 +14,27 @@ 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.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; 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 +43,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 +71,27 @@ @NonNullByDefault 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; private ExpiringCache consumables; private ExpiringCache dnd; private ExpiringCache history; + private int stateId; + private ExpiringCache map; private String lastHistoryId = ""; - private int inCleaning; + 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, () -> { try { @@ -109,6 +137,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 +159,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)) { @@ -186,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) { @@ -348,6 +391,11 @@ protected synchronized void updateData() { status.getValue(); refreshNetwork(); consumables.getValue(); + if (lastMap.isEmpty() || stateId != 8) { + if (isLinked(mapChannelUid)) { + map.getValue(); + } + } } catch (Exception e) { logger.debug("Error while updating '{}': '{}", getThing().getUID().toString(), e.getLocalizedMessage()); } @@ -395,6 +443,15 @@ 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.submit(() -> updateState(CHANNEL_VACUUM_MAP, getMap(mapresponse))); + } + } + break; case UNKNOWN: updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString())); break; @@ -402,4 +459,39 @@ public void onMessageReceived(MiIoSendCommand response) { break; } } + + private State getMap(String map) { + final MiIoBindingConfiguration configuration = this.configuration; + if (configuration != null && cloudConnector.isConnected()) { + try { + final @Nullable RawType mapDl = cloudConnector.getMap(map, + (configuration.cloudServer != null) ? configuration.cloudServer : ""); + if (mapDl != null) { + byte[] mapData = mapDl.getBytes(); + RRMapDraw rrMap = RRMapDraw.loadImage(new ByteArrayInputStream(mapData)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (logger.isDebugEnabled()) { + 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(); + 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..9ee93d4e6d950 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapDraw.java @@ -0,0 +1,391 @@ +/** + * 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.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.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +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 + * + * @author Marcel Verpaalen - Initial contribution + */ +@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); + 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 final @Nullable Bundle bundle = FrameworkUtil.getBundle(getClass()); + private boolean multicolor = false; + private final RRMapFileParser rmfp; + + private final Logger logger = LoggerFactory.getLogger(RRMapDraw.class); + + public RRMapDraw(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 MAP_OUTSIDE: + g2d.setColor(COLOR_MAP_OUTSIDE); + break; + case MAP_WALL: + g2d.setColor(COLOR_MAP_WALL); + break; + case MAP_INSIDE: + g2d.setColor(COLOR_MAP_INSIDE); + break; + case MAP_SCAN: + 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, "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, "robo.png", 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 imgFile, float x, float y) { + URL image = getImageUrl(imgFile); + try { + 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) { + logger.debug("Error loading image {}: {}", image, e.getMessage()); + } + } + + 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; + URL image = getImageUrl("ohlogo.png"); + try { + if (image != null) { + BufferedImage ohLogo = ImageIO.read(image); + 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) - (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("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 * scale) / stringWidth)); + g2d.setFont(font); + } + int stringHeight = fontMetrics.getAscent(); + g2d.setPaint(Color.white); + g2d.drawString(message, textPos, height - offset * scale - 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); + 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..13e8ff22d8621 --- /dev/null +++ b/bundles/org.openhab.binding.miio/src/main/java/org/openhab/binding/miio/internal/robot/RRMapFileParser.java @@ -0,0 +1,417 @@ +/** + * 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.io.PrintWriter; +import java.io.StringWriter; +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 final int majorVersion; + private final int minorVersion; + private final int mapIndex; + private final 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.debug("Blocktype: {}", Integer.toString(blocktype)); + logger.debug("Header len: {} data len: {} ", Integer.toString(blockHeaderLength), + Integer.toString(blockDataLength)); + logger.debug("H: {}", Utils.getSpacedHex(header)); + if (blockDataLength > 0) { + logger.debug("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() { + 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()) { + pw.print(area == NO_GO_AREAS ? "No Go zones:\t" : "MFBZS zones:\t"); + pw.printf("%d\r\n", areas.get(area).size()); + } + 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()) { + pw.printf("\r\nPath type:\t%d", p); + for (String detail : pathsDetails.get(p).keySet()) { + pw.printf(" %s: %d", detail, pathsDetails.get(p).get(detail)); + } + } + pw.println(); + pw.close(); + return sw.toString(); + } + + /** + * 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/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 fa9e0e61c162e..d9af9abbf2b80 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,15 @@ public int queueCommand(String command, String params) MiIoSendCommand sendCmd = new MiIoSendCommand(cmdId, MiIoCommand.getCommand(command), fullCommand.toString()); concurrentLinkedQueue.add(sendCmd); - logger.debug("Command added to Queue {} -> {} (Device: {} token: {} Queue: {})", fullCommand.toString(), ip, - Utils.getHex(deviceId), Utils.getHex(token), 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); } @@ -306,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); 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/main/resources/images/charger.png b/bundles/org.openhab.binding.miio/src/main/resources/images/charger.png new file mode 100644 index 0000000000000..d94e0138ac36e Binary files /dev/null and b/bundles/org.openhab.binding.miio/src/main/resources/images/charger.png differ 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 0000000000000..0f79fdaee7cf4 Binary files /dev/null and b/bundles/org.openhab.binding.miio/src/main/resources/images/ohlogo.png differ diff --git a/bundles/org.openhab.binding.miio/src/main/resources/images/robo.png b/bundles/org.openhab.binding.miio/src/main/resources/images/robo.png new file mode 100644 index 0000000000000..808c2d4b311b0 Binary files /dev/null and b/bundles/org.openhab.binding.miio/src/main/resources/images/robo.png differ 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..dffa2d00887bd --- /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"; + private final JFrame parent; + private final RRDrawPanel rrDrawPanel = new RRDrawPanel(); + private final JTextArea textArea = new JTextArea(); + private final JLabel statusbarL = new JLabel(); + private final 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 + } + } +}