Repository: mill1000/midea-msmart (python)

Issue:
Default cloud crendentials no longer valid worldwide
Hi Mill,
today i got an error regarding the Cloud credentials:

```
msmart-ng discover
INFO:msmart.cli:Discovering all devices on local network.
INFO:msmart.cloud:Using Midea cloud server: https://mp-prod.appsmb.com (China: False).
ERROR:msmart.discover:Failed to login to cloud. Error: Code: 3102, Message: Account or password incorrect, please re-enter
ERROR:msmart.discover:Could not establish cloud connection.
INFO:msmart.cli:Found 1 devices.
INFO:msmart.cli:Found device:
.....
```

Pull request description:
Add support for additional cloud credentials based on region
It appears the server are region locking accounts. This PR adds support for specifying a region which is used solely for selecting different built-in credentials. Close #176 

Patch:
diff --git a/msmart/cli.py b/msmart/cli.py
index 06d3efe..cb0dfd6 100644
--- a/msmart/cli.py
+++ b/msmart/cli.py
@@ -6,7 +6,7 @@ from typing import NoReturn
 
 from msmart import __version__
 from msmart.cloud import Cloud, CloudError
-from msmart.const import OPEN_MIDEA_APP_ACCOUNT, OPEN_MIDEA_APP_PASSWORD
+from msmart.const import CLOUD_CREDENTIALS, DEFAULT_CLOUD_REGION
 from msmart.device import AirConditioner as AC
 from msmart.discover import Discover
 from msmart.lan import AuthenticationError
@@ -15,16 +15,19 @@ from msmart.utils import MideaIntEnum
 _LOGGER = logging.getLogger(__name__)
 
 
+DEFAULT_CLOUD_ACCOUNT, DEFAULT_CLOUD_PASSWORD = CLOUD_CREDENTIALS[DEFAULT_CLOUD_REGION]
+
+
 async def _discover(args) -> None:
     """Discover Midea devices and print configuration information."""
 
     devices = []
     if args.host is None:
         _LOGGER.info("Discovering all devices on local network.")
-        devices = await Discover.discover(account=args.account, password=args.password, discovery_packets=args.count)
+        devices = await Discover.discover(region=args.region, account=args.account, password=args.password, discovery_packets=args.count)
     else:
         _LOGGER.info("Discovering %s on local network.", args.host)
-        dev = await Discover.discover_single(args.host, account=args.account, password=args.password, discovery_packets=args.count)
+        dev = await Discover.discover_single(args.host, region=args.region, account=args.account, password=args.password, discovery_packets=args.count)
         if dev:
             devices.append(dev)
 
@@ -52,7 +55,7 @@ async def _connect(args) -> AC:
     if args.auto:
         # Use discovery to automatically connect and authenticate with device
         _LOGGER.info("Discovering %s on local network.", args.host)
-        device = await Discover.discover_single(args.host, account=args.account, password=args.password)
+        device = await Discover.discover_single(args.host, region=args.region, account=args.account, password=args.password)
 
         if device is None:
             _LOGGER.error("Device not found.")
@@ -216,7 +219,7 @@ async def _download(args) -> None:
 
     # Use discovery to to find device information
     _LOGGER.info("Discovering %s on local network.", args.host)
-    device = await Discover.discover_single(args.host, account=args.account, password=args.password, auto_connect=False)
+    device = await Discover.discover_single(args.host, region=args.region, account=args.account, password=args.password, auto_connect=False)
 
     if device is None:
         _LOGGER.error("Device not found.")
@@ -232,7 +235,7 @@ async def _download(args) -> None:
         exit(1)
 
     # Get cloud connection
-    cloud = Cloud(args.account, args.password)
+    cloud = Cloud(args.region, account=args.account, password=args.password)
     try:
         await cloud.login()
     except CloudError as e:
@@ -270,7 +273,7 @@ def _run(args) -> NoReturn:
         logging.getLogger("httpcore").setLevel(logging.WARNING)
 
     # Validate common arguments
-    if args.china and (args.account == OPEN_MIDEA_APP_ACCOUNT or args.password == OPEN_MIDEA_APP_PASSWORD):
+    if args.china and (args.account is None or args.password is None):
         _LOGGER.error(
             "Account (phone number) and password of 美的美居 is required to use --china option.")
         exit(1)
@@ -299,14 +302,18 @@ def main() -> NoReturn:
     common_parser = argparse.ArgumentParser(add_help=False)
     common_parser.add_argument("-d", "--debug",
                                help="Enable debug logging.", action="store_true")
+    common_parser.add_argument("--region",
+                               help="Country/region for built-in cloud credential selection.",
+                               choices=CLOUD_CREDENTIALS.keys(),
+                               default=DEFAULT_CLOUD_REGION)
     common_parser.add_argument("--account",
-                               help="MSmartHome or 美的美居 username for discovery and automatic authentication",
-                               default=OPEN_MIDEA_APP_ACCOUNT)
+                               help="Manually specify a MSmart username for cloud authentication.",
+                               default=None)
     common_parser.add_argument("--password",
-                               help="MSmartHome or 美的美居 password for discovery and automatic authentication.",
-                               default=OPEN_MIDEA_APP_PASSWORD)
+                               help="Manually specify a MSmart password for cloud authentication.",
+                               default=None)
     common_parser.add_argument("--china",
-                               help="Use China server for discovery and automatic authentication.",
+                               help="Use China server for discovery and authentication. Username and password must be specified.",
                                action="store_true")
 
     # Setup discover parser
@@ -404,9 +411,9 @@ def _legacy_main() -> NoReturn:
     parser.add_argument(
         "-d", "--debug", help="Enable debug logging.", action="store_true")
     parser.add_argument(
-        "-a", "--account", help="MSmartHome or 美的美居 account username.", default=OPEN_MIDEA_APP_ACCOUNT)
+        "-a", "--account", help="MSmartHome or 美的美居 account username.", default=DEFAULT_CLOUD_ACCOUNT)
     parser.add_argument(
-        "-p", "--password", help="MSmartHome or 美的美居 account password.", default=OPEN_MIDEA_APP_PASSWORD)
+        "-p", "--password", help="MSmartHome or 美的美居 account password.", default=DEFAULT_CLOUD_PASSWORD)
     parser.add_argument(
         "-i", "--ip", help="IP address of a device. Useful if broadcasts don't work, or to query a single device.")
     parser.add_argument(
diff --git a/msmart/cloud.py b/msmart/cloud.py
index 64b6e8f..9477e62 100644
--- a/msmart/cloud.py
+++ b/msmart/cloud.py
@@ -13,7 +13,7 @@ import httpx
 from Crypto.Cipher import AES
 from Crypto.Util import Padding
 
-from msmart.const import DeviceType
+from msmart.const import CLOUD_CREDENTIALS, DEFAULT_CLOUD_REGION, DeviceType
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -54,14 +54,28 @@ class Cloud:
     # Default number of request retries
     RETRIES = 3
 
-    def __init__(self, account: str, password: str,
-                 use_china_server: bool = False) -> None:
+    def __init__(self,
+                 region: str = DEFAULT_CLOUD_REGION,
+                 *,
+                 account: Optional[str] = None,
+                 password: Optional[str] = None,
+                 use_china_server: bool = False
+                 ) -> None:
         # Allow override Chia server from environment
         if os.getenv("MIDEA_CHINA_SERVER", "0") == "1":
             use_china_server = True
 
-        self._account = account
-        self._password = password
+        # Validate incoming credentials and region
+        if account and password:
+            self._account = account
+            self._password = password
+        elif account or password:
+            raise ValueError("Account and password must be specified.")
+        else:
+            try:
+                self._account, self._password = CLOUD_CREDENTIALS[region]
+            except KeyError:
+                raise ValueError(f"Unknown cloud region '{region}'.")
 
         # Attributes that holds the login information of the current user
         self._login_id = None
diff --git a/msmart/const.py b/msmart/const.py
index c76bce2..b0b5df7 100644
--- a/msmart/const.py
+++ b/msmart/const.py
@@ -22,9 +22,12 @@ DEVICE_INFO_MSG = bytes([
     0xb7, 0xe4, 0x2d, 0x53, 0x49, 0x47, 0x62, 0xbe
 ])
 
-
-OPEN_MIDEA_APP_ACCOUNT = "midea@mailinator.com"
-OPEN_MIDEA_APP_PASSWORD = "this_is_a_password1"
+DEFAULT_CLOUD_REGION = "US"
+CLOUD_CREDENTIALS = {
+    "DE": ("midea_eu@mailinator.com", "das_ist_passwort1"),
+    "KR": ("midea_sea@mailinator.com", "password_for_sea1"),
+    "US": ("midea@mailinator.com", "this_is_a_password1")
+}
 
 
 class DeviceType(IntEnum):
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index 6af37eb..85ae3b8 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -1024,10 +1024,11 @@ class EnergyUsageResponse(Response):
                    100 * decode_bcd(d[1]) +
                    1 * decode_bcd(d[2]) +
                    0.01 * decode_bcd(d[3]))
+            # Scale binary energy by an extra power of 10. See mill1000/midea-msmart#174
             binary = ((d[0] << 24) +
                       (d[1] << 16) +
                       (d[2] << 8) +
-                      d[3]) / 10
+                      d[3]) / 100
             return bcd, binary
 
         def parse_power(d: bytes) -> tuple[float, float]:
diff --git a/msmart/discover.py b/msmart/discover.py
index 7c9c928..1153780 100644
--- a/msmart/discover.py
+++ b/msmart/discover.py
@@ -7,8 +7,7 @@ import xml.etree.ElementTree as ET
 from typing import Any, Optional, Type, cast
 
 from msmart.cloud import Cloud, CloudError
-from msmart.const import (DEVICE_INFO_MSG, DISCOVERY_MSG,
-                          OPEN_MIDEA_APP_ACCOUNT, OPEN_MIDEA_APP_PASSWORD,
+from msmart.const import (DEFAULT_CLOUD_REGION, DEVICE_INFO_MSG, DISCOVERY_MSG,
                           DeviceType)
 from msmart.device import AirConditioner, Device
 from msmart.lan import AuthenticationError, Security
@@ -135,8 +134,9 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
 class Discover:
     """Discover Midea smart devices on the local network."""
 
-    _account = OPEN_MIDEA_APP_ACCOUNT
-    _password = OPEN_MIDEA_APP_PASSWORD
+    _region = DEFAULT_CLOUD_REGION
+    _account = None
+    _password = None
     _lock = None
     _cloud = None
     _auto_connect = False
@@ -147,11 +147,12 @@ class Discover:
         *,
         target=_IPV4_BROADCAST,
         timeout=5,
-        discovery_packets=3,
+        discovery_packets: int = 3,
         interface=None,
-        account=None,
-        password=None,
-        auto_connect=True
+        region: str = DEFAULT_CLOUD_REGION,
+        account: Optional[str] = None,
+        password: Optional[str] = None,
+        auto_connect: bool = True
     ) -> list[Device]:
         """Discover devices via broadcast."""
 
@@ -162,8 +163,10 @@ class Discover:
         # Always use a new cloud connection
         cls._cloud = None
 
-        # Save cloud credentials
-        Discover._set_cloud_credentials(account, password)
+        # Save cloud region and credentials
+        cls._region = region
+        cls._account = account
+        cls._password = password
 
         # Save auto connect arg
         cls._auto_connect = auto_connect
@@ -212,16 +215,6 @@ class Discover:
 
         return None
 
-    @classmethod
-    def _set_cloud_credentials(cls, account, password) -> None:
-        """Set credentials for cloud access."""
-
-        if account and password:
-            cls._account = account
-            cls._password = password
-        elif account or password:
-            raise ValueError("Both account and password must be specified.")
-
     @classmethod
     async def _get_cloud(cls) -> Optional[Cloud]:
         """Return a cloud connection, creating it if necessary."""
@@ -232,7 +225,8 @@ class Discover:
         async with cls._lock:
             # Create cloud connection if nonexistent
             if cls._cloud is None:
-                cloud = Cloud(cls._account, cls._password)
+                cloud = Cloud(cls._region, account=cls._account,
+                              password=cls._password)
                 try:
                     await cloud.login()
                     cls._cloud = cloud


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index 28eacb1..fdede07 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -844,7 +844,7 @@ class TestGroupDataResponse(_TestResponseBase):
         """Test we decode binary energy usage responses correctly."""
         TEST_RESPONSES = {
             # https://github.com/mill1000/midea-ac-py/issues/204#issuecomment-2314705021
-            (150.4, .6, 279.5): bytes.fromhex("aa22ac00000000000803c1210144000005e00000000000000006000aeb000000487a5e"),
+            (15.04, .06, 279.5): bytes.fromhex("aa22ac00000000000803c1210144000005e00000000000000006000aeb000000487a5e"),
 
             # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218753545
             (None, None, None): bytes.fromhex("aa20ac00000000000303c1210144000000000000000000000000000000000843bc"),
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index dc82018..80a9abb 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -297,7 +297,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         """Test parsing of EnergyUsageResponses into device state with binary format."""
         TEST_RESPONSES = {
             # https://github.com/mill1000/midea-ac-py/issues/204#issuecomment-2314705021
-            (150.4, .6, 279.5): bytes.fromhex("aa22ac00000000000803c1210144000005e00000000000000006000aeb000000487a5e"),
+            (15.04, .06, 279.5): bytes.fromhex("aa22ac00000000000803c1210144000005e00000000000000006000aeb000000487a5e"),
 
             # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218753545
             (None, None, None): bytes.fromhex("aa20ac00000000000303c1210144000000000000000000000000000000000843bc"),
diff --git a/msmart/tests/test_cloud.py b/msmart/tests/test_cloud.py
index 2ab73c0..a769f29 100644
--- a/msmart/tests/test_cloud.py
+++ b/msmart/tests/test_cloud.py
@@ -1,15 +1,20 @@
 import unittest
+from typing import Any, Optional
 
 from msmart.cloud import ApiError, Cloud, CloudError
-from msmart.const import OPEN_MIDEA_APP_ACCOUNT, OPEN_MIDEA_APP_PASSWORD
+from msmart.const import DEFAULT_CLOUD_REGION
 
 
 class TestCloud(unittest.IsolatedAsyncioTestCase):
     # pylint: disable=protected-access
 
-    async def _login(self, account: str = OPEN_MIDEA_APP_ACCOUNT,
-                     password: str = OPEN_MIDEA_APP_PASSWORD) -> Cloud:
-        client = Cloud(account, password)
+    async def _login(self,
+                     region: str = DEFAULT_CLOUD_REGION,
+                     *,
+                     account: Optional[str] = None,
+                     password: Optional[str] = None
+                     ) -> Cloud:
+        client = Cloud(region, account=account, password=password)
         await client.login()
 
         return client
@@ -23,11 +28,27 @@ class TestCloud(unittest.IsolatedAsyncioTestCase):
         self.assertIsNotNone(client._access_token)
 
     async def test_login_exception(self) -> None:
-        """Test that we can login to the cloud."""
+        """Test that bad credentials raise an exception."""
 
         with self.assertRaises(ApiError):
             await self._login(account="bad@account.com", password="not_a_password")
 
+    async def test_invalid_region(self) -> None:
+        """Test that an invalid region raise an exception."""
+
+        with self.assertRaises(ValueError):
+            await self._login("NOT_A_REGION")
+
+    async def test_invalid_credentials(self) -> None:
+        """Test that invalid credentials raise an exception."""
+
+        # Check that specifying only an account or password raises an error
+        with self.assertRaises(ValueError):
+            await self._login(account=None, password="some_password")
+
+        with self.assertRaises(ValueError):
+            await self._login(account="some_account", password=None)
+
     async def test_get_token(self) -> None:
         """Test that a token and key can be obtained from the cloud."""
 
@@ -53,7 +74,7 @@ class TestCloud(unittest.IsolatedAsyncioTestCase):
     async def test_connect_exception(self) -> None:
         """Test that an exception is thrown when the cloud connection fails."""
 
-        client = Cloud(OPEN_MIDEA_APP_ACCOUNT, OPEN_MIDEA_APP_PASSWORD)
+        client = Cloud(DEFAULT_CLOUD_REGION)
 
         # Override URL to an invalid domain
         client._base_url = "https://fake_server.invalid."