├── MANIFEST.in ├── .gitattributes ├── pyproject.toml ├── src └── pyelectroluxconnect │ ├── __init__.py │ ├── certificatechain.pem │ ├── urls.py │ └── Session.py ├── setup.py ├── .gitignore ├── README.md └── LICENSE /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include certificatechain.pem 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /src/pyelectroluxconnect/__init__.py: -------------------------------------------------------------------------------- 1 | """A Python module to communicate with Elecrolux Connectivity Platform.""" 2 | 3 | __all__ = [ 4 | 'Error', 5 | 'LoginError', 6 | 'RequestError', 7 | 'ResponseError', 8 | 'Session' 9 | ] 10 | 11 | from .Session import ( 12 | Error, 13 | LoginError, 14 | RequestError, 15 | ResponseError, 16 | Session 17 | ) 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name='pyelectroluxconnect', 8 | version='0.3.3', 9 | description='Interface for Electrolux Connectivity Platform API', 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | url='https://github.com/tomeko12/pyelectroluxconnect', 13 | author='tomeko', 14 | license='Apache Software License', 15 | classifiers=[ 16 | 'Programming Language :: Python :: 3.10', 17 | 'License :: OSI Approved :: Apache Software License', 18 | 'Operating System :: OS Independent', 19 | 'Topic :: Home Automation', 20 | 'Development Status :: 4 - Beta' 21 | ], 22 | keywords='home automation electrolux aeg frigidaire husqvarna', 23 | package_dir={"": "src"}, 24 | packages=setuptools.find_packages(where="src"), 25 | install_requires=[ 26 | 'requests>=2.20.0', 27 | 'beautifulsoup4>=4,<5' 28 | ], 29 | package_data={'': ['certificatechain.pem']}, 30 | zip_safe=False, 31 | ) 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | .vscode/settings.json 154 | -------------------------------------------------------------------------------- /src/pyelectroluxconnect/certificatechain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIGhDCCBGygAwIBAgITGgAAAAc/e3pEbyrzwQAAAAAABzANBgkqhkiG9w0BAQsF 3 | ADBGMRMwEQYDVQQKEwpFbGVjdHJvbHV4MR0wGwYDVQQLExRDb25uZWN0ZWQgQXBw 4 | bGlhbmNlczEQMA4GA1UEAxMHUm9vdCBDQTAeFw0xNzAxMzAxNzE5MjVaFw0yOTAx 5 | MzAxNzI5MjVaMFAxEzARBgNVBAoTCkVsZWN0cm9sdXgxHTAbBgNVBAsTFENvbm5l 6 | Y3RlZCBBcHBsaWFuY2VzMRowGAYDVQQDExFJbmZyYXN0cnVjdHVyZSBDQTCCAiIw 7 | DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3XtHX9T+Yk6PMXdBlTQUK8cUN5 8 | qZeiS2EbTpTLviKU3P4rg1wC+eh9LupVzaIQFXeQ1F/IeF/cRLD/yj5KfS9kBez7 9 | 2pAH+n8LpRnpWrgCV5nGsUeDzG/NUSUBsdPPMmuL/0f155DoawFOwreye3U5z21I 10 | IRZkCvq153x2SArmIrKSPzIEm122kaeFP++fOCMbaDyrcuo2BSWLHxMtzceJcebq 11 | yFkmllA345mAwO8+nogco+VFW7PQAXbZw01MnJCCymwhUVdS1mrxDmG7M2FTUQ+u 12 | EgaUGpjdIvJuh4EgMPEXJJUbxRO0s7IvXcBwoEmUOSSvKH/R3aIWs4YXqFQrhBlL 13 | +86RTzS3ii1Mg6REyg/aed72vmHInjTOHdrpDOrUQqSMS9AeH5pK0upUeN+/BZ82 14 | WlIPC71mFwxSzRoJdqcEsjTkMX2dPSb5AF1ysruj8hAlWELr28+WiIuyIo0H4Fbb 15 | decueADA5i75G8GUfb5AoT+irZSbfK9luEB5vHC1N2w/ELYl2pnDNel/nt1FzRWv 16 | pvY/OIp1X5UdvK03/+z7JRPlfsmRvZUuJ5huYmN0Ep07iUCIl2hvOw6Uqe/21n0Z 17 | i0lJXhg1Wi0PiBiO45T3JytRFankY54I3ohAg2gKK6QKXSHPpd9STva8caO0xiWb 18 | MTJqE6lt/g1uRsM7AgMBAAGjggFfMIIBWzAQBgkrBgEEAYI3FQEEAwIBADAdBgNV 19 | HQ4EFgQU0xlnUEVjRcxFgHKR/mqJbah4U24wSwYDVR0gBEQwQjBABgorBgEEAYKv 20 | ZAMBMDIwMAYIKwYBBQUHAgEWJGh0dHA6Ly9pb3RlY2EuZWxlY3Ryb2x1eC5jb20v 21 | cG9saWN5ADAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYw 22 | DwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBT5MghcH9AfTwm7ZsbusEw7qGrz 23 | PjA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vaW90ZWNhLmVsZWN0cm9sdXguY29t 24 | L2NkcC9yb290LmNybDBFBggrBgEFBQcBAQQ5MDcwNQYIKwYBBQUHMAKGKWh0dHA6 25 | Ly9pb3RlY2EuZWxlY3Ryb2x1eC5jb20vYWlhL3Jvb3QuY3J0MA0GCSqGSIb3DQEB 26 | CwUAA4ICAQCrSIBSt1lpJJDVPtkAdDis3LKGZZXqo419dgcwLFFeMwUYFDq2KHTk 27 | 3uFJEBJO52BYl7D3G5O1x3C6FAI4t3bgmWYrTycTtFmr3gGtQxWoRkBfi5QdLtAn 28 | wr/gHubsCycX9OqFjs/mboyyaeTfgdSb1bHqS6/xfT/6Y7m0NQHed1QI9CERJ+DI 29 | o8/gwJTOgqyhywNKfi5FaR1YRxsMwGdpo7HEt9ne4Zcrq1lm5IGZ5obYtf3nmX6/ 30 | o0DcV3WFbU5Js1biQvuF7bl/28VC/r0ailLWI3vT1XNLbLpNYzJU8QGjUWms2qZ5 31 | ncQV07gukOh0E4g7OcgL1LDskYzpQmc1Qv+/6NkleziZPSNSjbvgUzjlDqnkq3Af 32 | DuhHL/6q/zua3B2HnTbR2OX+ouNuuAE4q+UfvQQpchqyTgveOiONCDnwVggQKwbK 33 | 7FD3Fm3sJE04Me0nFQ+6XkU59loPwPGhwlZ3qxz2/rzjVW1r5i0I0SLFkTjYd8lx 34 | /twBKPkoqzRn/CQn9XW14PR6Ga3UFgoG/Q2QO/zJ9bjUYdAKhMdzHy/F9wKfeD2Q 35 | z09zsdHWSim+C+K81Hiof+hDtrjc3JTjjbLFl1j2pUM9qD/ZtaYfwGnCVA9wkoXw 36 | qiBTJGBeH3IQixpxgRMC819bWhmlc09ex0p/d87/st8JgZxlGQQihA== 37 | -----END CERTIFICATE----- 38 | -----BEGIN CERTIFICATE----- 39 | MIIFZzCCA0+gAwIBAgIQEyXwe6Vvg7xBmE4edvyY+jANBgkqhkiG9w0BAQsFADBG 40 | MRMwEQYDVQQKEwpFbGVjdHJvbHV4MR0wGwYDVQQLExRDb25uZWN0ZWQgQXBwbGlh 41 | bmNlczEQMA4GA1UEAxMHUm9vdCBDQTAeFw0xNjExMTUxMDQyNTJaFw00MDExMTUx 42 | MDUyNTFaMEYxEzARBgNVBAoTCkVsZWN0cm9sdXgxHTAbBgNVBAsTFENvbm5lY3Rl 43 | ZCBBcHBsaWFuY2VzMRAwDgYDVQQDEwdSb290IENBMIICIjANBgkqhkiG9w0BAQEF 44 | AAOCAg8AMIICCgKCAgEAzSRatzjTTcUqqvdESTkC+7LBVyWfonW/PTAzeeLMH7FO 45 | KMAXQx7H7pj2S49jRbUwJmf+Ub08HakhAt4IUZIKIw17fGm8j7NlVjg3mh5/7o81 46 | APhSG9auAG7i58RQZdA/EwrF5zcDGj+fJMJQtJLUXOxJ/aCvVMO2MER6myrjLMVj 47 | DKV1n0d2iF/7F1f1oIJyWIP3I+2e2MEePAsyTtgcw32/hcMFU6r1SsJj/JHpk/Mb 48 | i6yjyJByP3v1ZHyG8awvWe1pqvjADFrrevObSmRv79bACnwjM0CH+BGaEtV9HEJn 49 | ooBFjOzy4MjEM2KbIFnmo2C3/BxgAZxKjE8uiA88pEWIIFmFI2GnRwLBhvlDDXLb 50 | kAZQ/xRZqFkv1aEyKxYxAIhmHbipzBX6AOhSug7qnhzp9LqCoa2MWX5cjs4bKwoI 51 | ITuamU4DJpHhGs4g1oDOZpQQw87c1cxyQycTEn7v1MnxsJsjYaOSUJ96rlOgva2A 52 | r6ywE7vS3m7wK7mGcETHgFOQtBha+Tybg3de+/PbAwbheAVqsEdKJ2WQhvdHzWWL 53 | Dez1KavlclezXo0PHjEwPq3pKgAD5fLbPI1fSl/1ZP3GLu1ngOquc1xfQWIAUjWS 54 | hv56+rQzfk3j15L7VaZiaqAXXw6pP6/Q2A9Wf0RWCYVN1IO91+MjsX5OTZrbX5sC 55 | AwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE 56 | FPkyCFwf0B9PCbtmxu6wTDuoavM+MBAGCSsGAQQBgjcVAQQDAgEAMA0GCSqGSIb3 57 | DQEBCwUAA4ICAQAUZRh9OxdCZ8ruWEucIx+VICAnNxKJwCPkulh4PBTmZf9O9VU8 58 | ELCybA5/456SF4a7Oz0xjO944Z4jHf0ydP6onankBTKMJYyzM3zMnyqnqWsR8Zah 59 | 23zwlxTLZwI6Jj6awuLPh+vAC4iVOrYY/U2Nf+alT/9CWcGlnjCG6JF/U0a7j9iG 60 | BQBGhFiAR11qjKGCZL9/lldKK3eBS3nutmVMFXGC/9PBBeBQoGRCkAeZ5B31R+je 61 | NcdFwy1XthGTjYYzZd/A8HIfg0qlPIAErQHxJwP6Dfddd8neewiWPESVkbV9h7tv 62 | I7JFfCsL2xzSsvnlpVuzG/nb+QhNh+Mi0R4k/COYCAsibhEcPkuB9ETMsCMX6zbV 63 | wtYJXLZTsqwNP4L82pEssQVeQE7oW5iu8Xg551oP3SysfHTvqa8N5Sj1b+SI/J1n 64 | ZGvgMhYlpsXVqTPViiMuLqfr1t9YeTqafWS4VV/BAhARIYfqJymS/Z/J3kU5p16Y 65 | zXx225gxQHQRTJzUlKVolHu45lllR2k5v1d4b86zmXr+jBqkrHQ5RvUrueG4MPwk 66 | aCKaaZs9J7OCFHJvhvyUs9fiVyeCYFh3qLPbcTJypyE+B+YuyZBCfOCceeIYXxX8 67 | f3xADikdOTnUStJTkOBsxq1aIeZ28MHohhDD+VHShtRyVDYtPmluoaf3fw== 68 | -----END CERTIFICATE----- 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyelectroluxconnect 2 | Python client package to communicate with the Electrolux Connectivity Platform (**ECP**) used by some home appliances, Electrolux owned brands, like: **Electrolux**, **AEG**, **Frigidaire**, **Husqvarna**. 3 | Tested with AEG washer-dryer, but probably could be used with some internet connected ovens, diswashers, fridges, airconditioners. 4 | It is general client, and all parameters (called HACL), that can be read or set, names and translations are dynamically generated, based on appliance profile file, downloaded from ECP servers. 5 | Appliance must be registered with one of the following ECP based applications: Electrolux Care, Electrolux Kitchen, Electrolux Life, Electrolux Oven, Electrolux Home+, AEG Care, AEG Kitchen, Frigidaire 2.0 6 | 7 | ## Features 8 | - list appliances paired with Electrolux account 9 | - get appliance profile with translations 10 | - get appliance state 11 | - send command to appliance 12 | - register/unregister Client with Electrolux MQTT cloud based broker 13 | 14 | ## Usage 15 | #### Initiate session 16 | To use this library, there's an account with Electrolux/AEG/Frigidaire app must be created. By default, library is using EMEA region apps credentials. If account is created in other region app, `region` parameter must be set. If created account is not supported, You can manually set `regionServer`, `customApiKey` and `customApiBrand` parameters (from sniffed traffic or extracted from mobile app). 17 | 18 | 19 | ```python 20 | import pyelectroluxconnect 21 | ses = pyelectroluxconnect.Session(username, password, region="emea", tokenFileName = ".electrolux-token", country = "US", language = None, deviceId = "CustomDeviceId", verifySsl = True, regionServer=None, customApiKey=None, customApiBrand=None) 22 | ``` 23 | 24 | or minimal input set: 25 | 26 | ```python 27 | import pyelectroluxconnect 28 | ses = pyelectroluxconnect.Session(username, password) 29 | ``` 30 | 31 | where: 32 | `username, password` - ECP (Electrolux site) credentials 33 | `tokenFileName` - file to store auth token (default: `~/.electrolux-token`) 34 | `region` - account region (defalt `emea`. Tested with `emea`, `apac`, `na`, `latam`, `frigidaire`) 35 | `country` - 2-char country code (default `US`) 36 | `language` - 3-char language code for translations (`All` - for all delivered languages, default: `None`) 37 | `deviceId` - custom id of client used in ECP, should be unique for every client instance (default: `CustomDeviceId`) 38 | `verifySsl` - verify ECP servers certs (default: `True`) 39 | `regionServer` - region server URL (default is based on selected region) 40 | `customApiKey` - custom value of "x-ibm-client-id" and "x-api-key" HTTP headers (default is based on selected region) 41 | `customApiBrand` - custom "brand" value (default is based on selected region) 42 | 43 | 44 | 45 | #### Login to ECP 46 | 47 | 48 | ```python 49 | ses.login() 50 | ``` 51 | 52 | #### Get list of appliances registered to Electrolux account 53 | 54 | ```python 55 | appllist = ses.getAppliances() 56 | print(appllist) 57 | ``` 58 | 59 | 60 | #### Get appliances connection state 61 | 62 | ```python 63 | for appliance in appllist: 64 | print(ses.getApplianceConnectionState(appliance)) 65 | ``` 66 | 67 | 68 | #### Get appliance profile 69 | List of parameters (HACL's) with allowed values, translations, etc... Note, that not all parameters can be read, or set over ECP. 70 | Each parameter is in "module:hacl" form. Module is internal appliance module symbol, hacl is parameter hex symbol, that can be read from or set to module. 71 | 72 | ```python 73 | print(ses.getApplianceProfile(appliance)) 74 | ``` 75 | 76 | 77 | #### Get appliance latest state from ECP 78 | Get latest appliance state from ECP. When appliance is online, current state updates are available over: 79 | - LAN with [AllJoyn](https://en.wikipedia.org/wiki/AllJoyn) protocol 80 | - Internet with [MQTT](https://en.wikipedia.org/wiki/MQTT) protocol. To get credentials to connect any MQTT client to ECP MQTT broker, use `registerMQTT()` method. 81 | 82 | 83 | to get latest state from a platform: 84 | 85 | ```python 86 | print(ses.getApplianceState(appliance, paramName = None, rawOutput = False)) 87 | ``` 88 | 89 | `paramName` - comma separated list of patrameter names (`None` (default) for all params) 90 | `rawOutput` - get list of parameters in received form. `False` (default): parse output to more friendly form (with translations, etc) 91 | 92 | 93 | #### Send param value to appliance 94 | Send value to appliance (list of supported appliance destinations (`destination`) and parameters (`hacl`) with allowed values (`value`), You can get with `getApplianceProfile()` method): 95 | ```python 96 | ses.setHacl(appliance, hacl, value, destination) 97 | ``` 98 | 99 | `hacl` - hex number of param (HACL) 100 | `value` - value to set (it could be number or list of parameters (for container HACL type)) 101 | `destination` - destination module name, from profile path (`NIU`, `WD1`, etc...) 102 | 103 | washer-dryer examples: 104 | - set Wash+Dry "Cottons" program, with "Extra Dry" dryness Level: 105 | 106 | ```python 107 | ses.setHacl(appliance, "0x1C09", [{"50":"0x0000"},{"12":"128"},{"6.32":1},{"6.33":1}], "WD1") 108 | ``` 109 | 110 | - pause program: 111 | 112 | ```python 113 | ses.setHacl(appliance, "0x0403", 4, "WD1") 114 | ``` 115 | 116 | 117 | #### Register client to MQTT broker 118 | 119 | ```python 120 | print(ses.registerMQTT()) 121 | ``` 122 | 123 | returns parameters required to login to Electrolux MQTT broker with any MQTT client: 124 | `Url` - Host of MQTT broker (with port number) 125 | `OrgId` - Organization ID 126 | `ClientId` - MQTT Client ID 127 | `DeviceToken` - Token required to authentication (for IBM broker, use `use-token-auth` as username, DeviceToken as password) 128 | 129 | List of MQTT topics (QoS = 0) to subscribe: 130 | - `iot-2/cmd/live_stream/fmt/+` 131 | - `iot-2/cmd/feature_stream/fmt/+` 132 | 133 | #### Unregister client from MQTT broker 134 | 135 | ```python 136 | ses.unregisterMQTT() 137 | ``` 138 | 139 | ## Disclaimer 140 | This library was not made by AB Electrolux. It is not official, not developed, and not supported by AB Electrolux. 141 | -------------------------------------------------------------------------------- /src/pyelectroluxconnect/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | List of some ECP URLs and strings 3 | """ 4 | 5 | import re 6 | from urllib.parse import quote_plus 7 | 8 | BASE_URL = "https://api.emea.ecp.electrolux.com" 9 | X_API_KEY = "714fc3c7-ad68-4c2f-9a1a-b3dbe1c8bb35" 10 | BRAND = "Electrolux" 11 | 12 | _region_params = { 13 | "emea": ["https://api.emea.ecp.electrolux.com", 14 | "714fc3c7-ad68-4c2f-9a1a-b3dbe1c8bb35", 15 | "Electrolux"], 16 | "apac": ["https://api.apac.ecp.electrolux.com", 17 | "1c064d7a-c02e-438c-9ac6-78bf7311ba7c", 18 | "Electrolux"], 19 | "na": ["https://api.latam.ecp.electrolux.com", 20 | "dc9cfac1-4a29-4509-9041-9ae4a0572aac", 21 | "Electrolux-NA"], 22 | "latam": ["https://api.latam.ecp.electrolux.com", 23 | "3aafa8f0-9fd8-454d-97f6-f46e87b280e2", 24 | "Electrolux"], 25 | "frigidaire": ["https://api.latam.ecp.electrolux.com", 26 | "7ff2358e-8d6d-4cf6-814a-fcb498fa2cf9", 27 | "frigidaire"] 28 | } 29 | 30 | 31 | def getEcpClientUrl(region): 32 | if region.lower() in _region_params: 33 | return _region_params[region.lower()][0] 34 | else: 35 | return BASE_URL 36 | 37 | 38 | def getEcpClientId(region): 39 | if region.lower() in _region_params: 40 | return _region_params[region.lower()][1] 41 | else: 42 | return X_API_KEY 43 | 44 | 45 | def getEcpClientBrand(region): 46 | if region.lower() in _region_params: 47 | return _region_params[region.lower()][2] 48 | else: 49 | return BRAND 50 | 51 | 52 | # Authenticate (get Session key) 53 | def login(): 54 | return ["{base_url}/authentication/authenticate".format( 55 | base_url=BASE_URL), 56 | "POST" 57 | ] 58 | 59 | # Get appliances list registered to account 60 | 61 | 62 | def getAppliances(username): 63 | return ["{base_url}/user-appliance-reg/users/{username}/appliances".format( 64 | base_url=BASE_URL, 65 | username=re.sub("(?i)\%2f", "f", quote_plus(username))), 66 | "GET" 67 | ] 68 | 69 | # Get general HACL map 70 | 71 | 72 | def getHaclMap(): 73 | return ["{base_url}/config-files/haclmap".format( 74 | base_url=BASE_URL), 75 | "GET" 76 | ] 77 | 78 | # Get list of supported appliances 79 | 80 | 81 | def getApplianceConfigurations(): 82 | return ["{base_url}/config-files/configurations".format( 83 | base_url=BASE_URL), 84 | "GET" 85 | ] 86 | 87 | # Get appliance connection state 88 | 89 | 90 | def getApplianceConnectionState(appliance): 91 | return ["{base_url}/elux-ms/appliances/latest?pnc={pnc}&elc={elc}&sn={sn}&states=ConnectivityState&includeSubcomponents=false".format( 92 | base_url=BASE_URL, 93 | pnc=re.sub("(?i)\%2f", "f", quote_plus(appliance["pnc"])), 94 | sn=re.sub("(?i)\%2f", "f", quote_plus(appliance["sn"])), 95 | elc=re.sub("(?i)\%2f", "f", quote_plus(appliance["elc"]))), 96 | "GET" 97 | ] 98 | 99 | # Get appliance parameter state 100 | 101 | 102 | def getApplianceParameterState(appliance, parameter): 103 | return ["{base_url}/elux-ms/appliances/latest?pnc={pnc}&elc={elc}&sn={sn}&states={param}&includeSubcomponents=true".format( 104 | base_url=BASE_URL, 105 | pnc=re.sub("(?i)\%2f", "f", quote_plus(appliance["pnc"])), 106 | sn=re.sub("(?i)\%2f", "f", quote_plus(appliance["sn"])), 107 | elc=re.sub("(?i)\%2f", "f", quote_plus(appliance["elc"])), 108 | param=re.sub("(?i)\%2f", "f", quote_plus(parameter))), 109 | "GET" 110 | ] 111 | 112 | # Get all appliance parameters state 113 | 114 | 115 | def getApplianceAllStates(appliance): 116 | return ["{base_url}/elux-ms/appliances/latest?pnc={pnc}&elc={elc}&sn={sn}&includeSubcomponents=true".format( 117 | base_url=BASE_URL, 118 | pnc=re.sub("(?i)\%2f", "f", quote_plus(appliance["pnc"])), 119 | sn=re.sub("(?i)\%2f", "f", quote_plus(appliance["sn"])), 120 | elc=re.sub("(?i)\%2f", "f", quote_plus(appliance["elc"]))), 121 | "GET" 122 | ] 123 | 124 | # Send command do appliance 125 | 126 | 127 | def setApplianceCommand(appliance): 128 | return ["{base_url}/commander/remote/sendjson?pnc={pnc}&elc={elc}&sn={sn}&mac={mac}".format( 129 | base_url=BASE_URL, 130 | pnc=re.sub("(?i)\%2f", "f", quote_plus(appliance["pnc"])), 131 | sn=re.sub("(?i)\%2f", "f", quote_plus(appliance["sn"])), 132 | elc=re.sub("(?i)\%2f", "f", quote_plus(appliance["elc"])), 133 | mac=re.sub("(?i)\%2f", "f", quote_plus(appliance["mac"]))), 134 | "POST" 135 | ] 136 | 137 | # Get selected appliance configuration 138 | 139 | 140 | def getApplianceConfigurationVersion(appliance): 141 | return ["{base_url}/config-files/configurations/search?pnc={pnc}&elc={elc}&serial_number={sn}".format( 142 | base_url=BASE_URL, 143 | pnc=re.sub("(?i)\%2f", "f", quote_plus(appliance["pnc"])), 144 | sn=re.sub("(?i)\%2f", "f", quote_plus(appliance["sn"])), 145 | elc=re.sub("(?i)\%2f", "f", quote_plus(appliance["elc"]))), 146 | "GET" 147 | ] 148 | 149 | # Download configuration file 150 | 151 | 152 | def getApplianceConfigurationFile(configurationId): 153 | return ["{base_url}/config-files/configurations/{configurationId}/bundle".format( 154 | base_url=BASE_URL, 155 | configurationId=re.sub("(?i)\%2f", "f", quote_plus(configurationId))), 156 | "GET" 157 | ] 158 | 159 | # Register Client to MQTT broker 160 | 161 | 162 | def registerMQTT(): 163 | return ["{base_url}/livesubscribe/livestream/register".format( 164 | base_url=BASE_URL), 165 | "POST" 166 | ] 167 | 168 | # Unregister Client from MQTT broker 169 | 170 | 171 | def unregisterMQTT(): 172 | return ["{base_url}/livesubscribe/livestream/unregister".format( 173 | base_url=BASE_URL), 174 | "POST" 175 | ] 176 | 177 | # Find docs by PNC 178 | 179 | 180 | def getDocsTable(appliance): 181 | return ["https://www.electrolux-ui.com/SearchResults.aspx?PNC={_pnc}{_elc}&ModelDenomination=&Language=&DocumentType=&Brand=".format( 182 | _pnc=re.sub("(?i)\%2f", "f", quote_plus(appliance["pnc"])), 183 | _elc=re.sub("(?i)\%2f", "f", quote_plus(appliance["elc"]))), 184 | "GET" 185 | ] 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/pyelectroluxconnect/Session.py: -------------------------------------------------------------------------------- 1 | """ Implements API wrapper to Electolux Connectivity Platform """ 2 | 3 | import hashlib 4 | import json 5 | import os 6 | import requests 7 | import time 8 | import urllib3 9 | import zipfile 10 | import logging 11 | 12 | from pyelectroluxconnect import urls 13 | from pathlib import Path 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | def _validate_response(response): 18 | """ Verify that response is OK """ 19 | if response.status_code == 200: 20 | return 21 | raise ResponseError(response.status_code, response.text) 22 | 23 | 24 | class Error(Exception): 25 | """ Session error """ 26 | pass 27 | 28 | 29 | class RequestError(Error): 30 | """ Wrapped requests.exceptions.RequestException """ 31 | pass 32 | 33 | class LoginError(Error): 34 | """ Login failed """ 35 | pass 36 | 37 | 38 | class ResponseError(Error): 39 | """ Unexcpected response """ 40 | 41 | def __init__(self, status_code, text): 42 | super(ResponseError, self).__init__( 43 | "Invalid response" 44 | ", status code: {0} - Data: {1}".format( 45 | status_code, 46 | text)) 47 | self.status_code = status_code 48 | self.text = text 49 | 50 | 51 | class Session(object): 52 | """ 53 | Session object 54 | """ 55 | 56 | def __init__( 57 | self, 58 | username, 59 | password, 60 | tokenFileName="~/.electrolux-token", 61 | country="US", 62 | language=None, 63 | deviceId="CustomDeviceId", 64 | verifySsl=True, 65 | region="emea", 66 | regionServer=None, 67 | customApiKey=None, 68 | customApiBrand=None): 69 | """ 70 | username, password - Electrolux platform credentials 71 | country - 2-char country code 72 | language - 3-char language code for translations (All - for all delivered languages) 73 | tokenFileName - file to store auth token 74 | deviceId - custom id of Electrolux platform client 75 | verifySsl - verify Electrolux platform servers certs 76 | region = region name (currently tested: "emea", "apac", "latam", "na", "frigidaire") 77 | regionServer - region server URL (default - EMEA server) 78 | customApiKey - custom value of "x-ibm-client-id" and "x-api-key" HTTP headers 79 | customApiBrand - custom "brand" value (default is based on selected region) 80 | """ 81 | 82 | self._username = username 83 | self._password = password 84 | self._country = country 85 | self._language = language 86 | self._region = region 87 | self._deviceId = deviceId 88 | self._tokenFileName = os.path.expanduser(tokenFileName) 89 | self._sessionToken = None 90 | self._applianceIndex = {} 91 | self._applianceProfiles = {} 92 | self._applianceTranslations = {} 93 | 94 | if verifySsl is False: 95 | urllib3.disable_warnings( 96 | urllib3.exceptions.InsecureRequestWarning) 97 | self._verifySsl = verifySsl 98 | else: 99 | self._verifySsl = os.path.join(os.path.dirname(__file__), 100 | "certificatechain.pem") 101 | 102 | if regionServer is not None: 103 | urls.BASE_URL = regionServer 104 | elif region is not None: 105 | urls.BASE_URL = urls.getEcpClientUrl(region) 106 | 107 | if customApiKey is not None: 108 | urls.X_API_KEY = customApiKey 109 | 110 | if customApiBrand is not None: 111 | urls.BRAND = customApiBrand 112 | 113 | def __enter__(self): 114 | self.login() 115 | return self 116 | 117 | def __exit__(self, exc_type, exc_val, exc_tb): 118 | self.logout() 119 | self.unregisterMQTT() 120 | 121 | def _headers(self): 122 | headers = { 123 | "x-ibm-client-id": urls.getEcpClientId(self._region), 124 | "x-api-key": urls.getEcpClientId(self._region), 125 | "Content-Type": "application/json" 126 | } 127 | if self._sessionToken: 128 | headers["session_token"] = self._sessionToken 129 | return headers 130 | 131 | def _createToken(self): 132 | """ 133 | Creating token by authenticate 134 | """ 135 | 136 | _payload = { 137 | "brand": urls.getEcpClientBrand(self._region), 138 | "country": self._country, 139 | "deviceId": self._deviceId, 140 | "password": self._password, 141 | "username": self._username 142 | } 143 | 144 | _LOGGER.debug("Getting new auth token") 145 | 146 | try: 147 | loginResp = json.loads( 148 | self._requestHttp( 149 | urls.login(), _payload).text) 150 | if loginResp["status"] == "OK": 151 | self._sessionToken = loginResp["data"]["sessionKey"] 152 | else: 153 | _LOGGER.error(f'Unable to get session token: {loginResp["message"]}') 154 | raise Error(loginResp["message"]) 155 | 156 | except ResponseError as ex: 157 | if(ex.status_code == 401 158 | or json.loads(ex.text)["code"] == "AER0802" 159 | or json.loads(ex.text)["code"] == "ECP0108"): 160 | _LOGGER.error(f'Login error: {json.loads(ex.text)["message"]}') 161 | raise LoginError(json.loads(ex.text)["message"]) from None 162 | else: 163 | _LOGGER.error(f'Authenticate error: {json.loads(ex.text)["message"]}') 164 | raise Error(json.loads(ex.text)["message"]) from None 165 | 166 | def _getAppliancesList(self): 167 | """ 168 | Get user registered appliances list 169 | """ 170 | 171 | _json = json.loads( 172 | self._requestHttp( 173 | urls.getAppliances(self._username)).text) 174 | 175 | if(_json["status"] == "ERROR"): 176 | raise ResponseError(_json["code"], _json["message"]) 177 | 178 | for device in _json["data"]: 179 | if device: 180 | self._applianceIndex[device["appliance_id"]] = { 181 | key: device[key] for 182 | key in device if key in ["pnc", "elc", "sn", "mac", "cpv"] 183 | } 184 | 185 | if "nickname" in device: 186 | self._applianceIndex[device["appliance_id"]]["alias"] = device["nickname"] 187 | else: 188 | self._applianceIndex[device["appliance_id"]]["alias"] = "" 189 | 190 | self._getApplianceConfiguration(device["appliance_id"]) 191 | 192 | def _getApplianceConfiguration(self, applianceId): 193 | """ 194 | Get appliance configuration file 195 | """ 196 | 197 | appliance = self._applianceIndex.get(applianceId) 198 | 199 | if(appliance): 200 | _json = json.loads( 201 | self._requestHttp( 202 | urls.getApplianceConfigurationVersion(appliance)).text) 203 | 204 | if(_json["status"] == "OK"): 205 | applianceConfigFileName = list( 206 | _json["data"][0]["configuration_file"])[0] 207 | deviceConfigId = _json["data"][0]["configuration_id"] 208 | applianceConfigFilePath = os.path.join( 209 | str(Path.home()), applianceConfigFileName) 210 | 211 | """ Checking proper appliance configuration file exists""" 212 | if not((os.path.exists(applianceConfigFilePath) 213 | and f'md5-{hashlib.md5(open(applianceConfigFilePath,"rb").read()).hexdigest()}' == 214 | _json["data"][0]["configuration_file"][applianceConfigFileName]["digest"])): 215 | try: 216 | _zipFile = self._requestHttp( 217 | urls.getApplianceConfigurationFile(deviceConfigId)) 218 | open(applianceConfigFilePath, "wb").write( 219 | _zipFile.content) 220 | except requests.exceptions.RequestException as ex: 221 | _LOGGER.error(f'Request error: {str(ex)}') 222 | raise RequestError(ex) 223 | 224 | if(os.path.exists(applianceConfigFilePath) 225 | and f'md5-{hashlib.md5(open(applianceConfigFilePath,"rb").read()).hexdigest()}' == 226 | _json["data"][0]["configuration_file"][applianceConfigFileName]["digest"]): 227 | with zipfile.ZipFile(applianceConfigFilePath, "r") as archive: 228 | self._applianceTranslations[id] = {} 229 | 230 | _json = json.loads( 231 | archive.read( 232 | f'{archive.namelist()[0]}profile.json')) 233 | _profile = self._parseProfileFile(_json, applianceId) 234 | 235 | _json = json.loads( 236 | archive.read( 237 | f'{archive.namelist()[0]}{next(item for item in _json["bundles"] if item["type"] == "Localization")["path"]}')) 238 | 239 | self._applianceTranslations[applianceId] = self._parseLocale_bundleFile( 240 | _json) 241 | self._applianceProfiles[applianceId] = self._createApplianceProfile( 242 | applianceId, 243 | _profile 244 | ) 245 | else: 246 | _LOGGER.error("Unable to get device configuration file.") 247 | raise Exception("Unable to get device configuration file.") 248 | else: 249 | _LOGGER.error(f"Unable to get configuration file version: {_json['message']}") 250 | raise Exception(_json["message"]) 251 | 252 | def _parseProfileFile(self, _json, applianceId): 253 | """ 254 | Parse device profile.json file 255 | """ 256 | result = {} 257 | 258 | self._applianceIndex[applianceId]["group"] = _json["group"] 259 | if("brand" in _json and _json["brand"] != ""): 260 | self._applianceIndex[applianceId]["brand"] = _json["brand"] 261 | else: 262 | self._applianceIndex[applianceId]["brand"] = "Electrolux" 263 | if(_json["model_name"] == ""): 264 | _LOGGER.info("No model name in profile file, try to find in other sites") 265 | self._applianceIndex[applianceId]["model"] = self._findModel( 266 | applianceId) 267 | else: 268 | self._applianceIndex[applianceId]["model"] = _json["model_name"] 269 | 270 | result["id"] = [] 271 | for modules in _json["modules"]: 272 | self._parseProfileModule(result, modules) 273 | 274 | return result 275 | 276 | def _parseProfileModule(self, result, modules): 277 | moduleName = modules["path"].split("/")[-1] 278 | for component in modules["components"]: 279 | if "hacl" in component: 280 | result[f'{moduleName}:{component["hacl"]["name"]}'] = self._parseProfileFileEntry( 281 | modules["path"], component) 282 | result[f'{moduleName}:{component["hacl"]["name"]}']["source"] = moduleName 283 | elif "id" in component and "parent_interfaces" in component: 284 | _identry = self._parseProfileFileEntry( 285 | modules["path"], component) 286 | _identry["id"] = component["id"] 287 | _identry["parent_interfaces"] = component["parent_interfaces"] 288 | result["id"].append(_identry) 289 | if("modules" in modules): 290 | for innermodules in modules["modules"]: 291 | self._parseProfileModule(result, innermodules) 292 | 293 | def _parseProfileFileEntry(self, path, component): 294 | result = {key: component[key] for 295 | key in component if key in 296 | [ 297 | "name", 298 | "namespace", 299 | "type", 300 | "data_format", 301 | "visibility", 302 | "access", 303 | "min_value", 304 | "max_value", 305 | "increment", 306 | "type" 307 | ] 308 | } 309 | result["path"] = path 310 | result["data_format"] = component["data_format"]["format"] 311 | if "unit" in component: 312 | result["unit"] = component["unit"]["source_format"] 313 | if "metadata" in component: 314 | if "localization_key" in component["metadata"]: 315 | result["locale_key"] = component["metadata"]["localization_key"] 316 | else: 317 | result["locale_key"] = "" 318 | else: 319 | result["locale_key"] = "" 320 | if "steps" in component: 321 | _compsteps = {} 322 | for step in component["steps"]: 323 | if step["value"] not in _compsteps: 324 | _compsteps[step["value"]] = {} 325 | if "metadata" in step: 326 | if "localization_key" in step["metadata"]: 327 | _compsteps[step["value"] 328 | ]["locale_key"] = step["metadata"]["localization_key"] 329 | else: 330 | _compsteps[step["value"]]["locale_key"] = "" 331 | if "key" in step: 332 | _compsteps[step["value"]]["key"] = step["key"] 333 | 334 | if len(_compsteps) > 0: 335 | result["steps"] = _compsteps 336 | if "permissions" in component: 337 | _compperm = {} 338 | for _permission in component["permissions"]: 339 | if _permission["ability"] in ["visibility", "access"]: 340 | _compperm[_permission["ability"]] = _permission["value"] 341 | if len(_compperm) > 0: 342 | result.update(_compperm) 343 | return result 344 | 345 | def _parseLocale_bundleFile(self, _json): 346 | """ 347 | Parse device locale_bundle.json file 348 | """ 349 | result = {} 350 | for item in _json["locale_bundles"]: 351 | result[item["locale_key"]] = {} 352 | for transitem in item["localizations"]: 353 | result[item["locale_key"]][transitem["locale"] 354 | ] = transitem["translation"] 355 | return result 356 | 357 | def _parseApplianceProfileContainer(self, applianceId, profileContainer, applianceParsedProfile): 358 | result = {} 359 | _idlists = list(filter(lambda item: f'{profileContainer["namespace"]}.{profileContainer["name"]}' in item["parent_interfaces"], 360 | applianceParsedProfile["id"])) 361 | for _idlist in _idlists: 362 | result[_idlist["id"]] = {key: _idlist[key] for 363 | key in _idlist if key in 364 | [ 365 | "name", 366 | "visibility", 367 | "access", 368 | "unit", 369 | "min_value", 370 | "max_value", 371 | "increment", 372 | "type", 373 | "data_format", 374 | ] 375 | } 376 | if(_idlist["type"] == "Container"): 377 | _subcontainer = self._parseApplianceProfileContainer( 378 | applianceId, _idlist, applianceParsedProfile) 379 | result[_idlist["id"]].update(_subcontainer) 380 | elif(_idlist["data_format"] == "array(struct)"): 381 | _subcontainer = {} 382 | _subcontainer["list"] = self._parseApplianceProfileContainer( 383 | applianceId, _idlist, applianceParsedProfile) 384 | result[_idlist["id"]].update(_subcontainer) 385 | else: 386 | result[_idlist["id"]]["data_format"] = _idlist["data_format"] 387 | 388 | if("steps" in _idlist): 389 | result[_idlist["id"]]["steps"] = {} 390 | for _containerstepkey, _containerstepvalue in _idlist["steps"].items(): 391 | result[_idlist["id"]]["steps"][_containerstepkey] = {} 392 | if("locale_key" in _containerstepvalue): 393 | result[_idlist["id"]]["steps"][_containerstepkey]["transl"] = self._getTranslation( 394 | applianceId, _containerstepvalue["locale_key"]) 395 | if("key" in _containerstepvalue): 396 | result[_idlist["id"] 397 | ]["steps"][_containerstepkey]["key"] = _containerstepvalue["key"] 398 | if _idlist["locale_key"] in self._applianceTranslations[applianceId]: 399 | result[_idlist["id"]]["nameTransl"] = self._getTranslation( 400 | applianceId, _idlist["locale_key"]) 401 | return result 402 | 403 | def _createApplianceProfile(self, 404 | applianceId, 405 | applianceParsedProfile): 406 | result = {} 407 | if(len(applianceParsedProfile) == 0): 408 | return None 409 | 410 | for _profkey, _profval in applianceParsedProfile.items(): 411 | if ("0x" in _profkey): 412 | result[_profkey] = {key: _profval[key] for 413 | key in _profval if key in 414 | [ 415 | "name", 416 | "data_format", 417 | "visibility", 418 | "access", 419 | "unit", 420 | "min_value", 421 | "max_value", 422 | "increment", 423 | "path", 424 | "type", 425 | "source", 426 | ] 427 | } 428 | if _profval["locale_key"] in self._applianceTranslations[applianceId]: 429 | result[_profkey]["nameTransl"] = self._getTranslation( 430 | applianceId, _profval["locale_key"]) 431 | if("steps" in _profval): 432 | result[_profkey]["steps"] = [] 433 | for _stepval, _steplangkey in _profval["steps"].items(): 434 | if("locale_key" in _steplangkey and _steplangkey["locale_key"] in self._applianceTranslations[applianceId]): 435 | result[_profkey]["steps"].append( 436 | {_stepval: self._getTranslation(applianceId, _steplangkey["locale_key"])}) 437 | if("type" in _profval and (_profval["type"] == "Container" or _profval["data_format"] == "array(struct)")): 438 | result[_profkey]["container"] = [] 439 | _container = self._parseApplianceProfileContainer( 440 | applianceId, _profval, applianceParsedProfile) 441 | result[_profkey]["container"].append(_container) 442 | return result 443 | 444 | def _parseApplianceState(self, 445 | stats, 446 | applianceId, 447 | rawOutput=False): 448 | result = {} 449 | if(not rawOutput and len(stats) > 0): 450 | for _item in stats: 451 | _hexHacl = f'{_item["source"]}:0x{_item["haclCode"]}' 452 | result[_hexHacl] = {key: _item[key] for 453 | key in _item if key not in 454 | [ 455 | "haclCode", 456 | "containers", 457 | "description" 458 | ]} 459 | if(_hexHacl in self._applianceProfiles[applianceId]): 460 | result[_hexHacl].update( 461 | {key: self._applianceProfiles[applianceId][_hexHacl][key] for 462 | key in self._applianceProfiles[applianceId][_hexHacl] if key in 463 | [ 464 | "name", 465 | "visibility", 466 | "access", 467 | "unit", 468 | "nameTransl" 469 | ] 470 | }) 471 | 472 | if("steps" in self._applianceProfiles[applianceId][_hexHacl]): 473 | for _step in self._applianceProfiles[applianceId][_hexHacl]["steps"]: 474 | if "numberValue" in _item and str(_item["numberValue"]) in _step: 475 | result[_hexHacl]["valueTransl"] = _step[str( 476 | _item["numberValue"])] 477 | elif "stringValue" in _item and _item["stringValue"] in _step: 478 | result[_hexHacl]["valueTransl"] = _step[_item["stringValue"]] 479 | if("containers" in _item and len(_item["containers"]) > 0 and 480 | _hexHacl in self._applianceProfiles[applianceId] and 481 | "container" in self._applianceProfiles[applianceId][_hexHacl] 482 | ): 483 | result[_hexHacl]["container"] = self._parseApplianceStateContainer( 484 | _item["containers"], 485 | self._applianceProfiles[applianceId][_hexHacl]["container"], 486 | ) 487 | else: 488 | result = stats 489 | return result 490 | 491 | def _parseApplianceStateItem(self, 492 | profileItem, 493 | stateItem): 494 | result = {} 495 | result[profileItem[0]] = {key: profileItem[1][key] for 496 | key in profileItem[1] if key not in 497 | [ 498 | "steps", 499 | "increment", 500 | "min_value", 501 | "max_value"]} 502 | result[profileItem[0]].update({key: stateItem[key] for 503 | key in stateItem if key not in 504 | [ 505 | "translation"]}) 506 | if ("steps" in profileItem[1]): 507 | stepKey = None 508 | if (stateItem["numberValue"] in profileItem[1]["steps"]): 509 | stepKey = stateItem["numberValue"] 510 | elif (str(stateItem["numberValue"]) in profileItem[1]["steps"]): 511 | stepKey = str(stateItem["numberValue"]) 512 | elif (f'0x{format(stateItem["numberValue"], "04X")}' in profileItem[1]["steps"]): 513 | stepKey = f'0x{format(stateItem["numberValue"], "04X")}' 514 | if (stepKey is not None and stepKey in profileItem[1]["steps"] and 515 | profileItem[1]["steps"][stepKey] not in ["", "UNIT"]): 516 | result[profileItem[0] 517 | ]["valTransl"] = profileItem[1]["steps"][stepKey]["transl"] 518 | if ("unit" in profileItem[1]): 519 | result[profileItem[0]]["unit"] = profileItem[1]["unit"] 520 | if(profileItem[1]["data_format"] == "array(struct)"): 521 | result[profileItem[0]]["list"] = self._parseApplianceStateItem( 522 | profileItem[1][key], stateItem) 523 | return result 524 | 525 | def _parseApplianceStateContainer(self, 526 | stateContainer, 527 | profileContainer): 528 | result = {} 529 | if(stateContainer): 530 | for profileItem in profileContainer[0].items(): 531 | if(profileItem[1]["name"] != "List"): 532 | for stateItem in stateContainer: 533 | if stateItem["propertyName"] == profileItem[1]["name"]: 534 | result.update( 535 | self._parseApplianceStateItem( 536 | profileItem, stateItem) 537 | ) 538 | else: 539 | result["list"] = {} 540 | for profileListItem in profileItem[1]["list"].items(): 541 | for stateItem in stateContainer: 542 | if stateItem["propertyName"] == profileListItem[1]["name"]: 543 | result["list"].update( 544 | self._parseApplianceStateItem( 545 | profileListItem, stateItem) 546 | ) 547 | return result 548 | 549 | def _sendApplianceCommand(self, 550 | applianceId, 551 | params, 552 | destination, 553 | source="RP1", 554 | operationMode="EXE", 555 | version="ad"): 556 | """ 557 | Send command to Electolux platform 558 | """ 559 | appliance = self._applianceIndex.get(applianceId) 560 | 561 | components = [] 562 | 563 | for param in params: 564 | if(not isinstance(param, dict)): 565 | raise Error("Parameters to send must be list of dicts") 566 | for key in param: 567 | if(param[key] == "Container"): 568 | components.append( 569 | {"name": key.removeprefix("0x"), "value": "Container"}) 570 | else: 571 | _intVal = 0 572 | if(isinstance(param[key], str) 573 | and param[key].startswith("0x")): 574 | _intVal = int(param[key].removeprefix("0x"), 16) 575 | elif(isinstance(param[key], str)): 576 | _intVal = int(param[key], 10) 577 | else: 578 | _intVal = param[key] 579 | components.append( 580 | {"name": key.removeprefix("0x"), "value": _intVal}) 581 | if(appliance): 582 | _payload = { 583 | "components": components, 584 | "destination": destination, 585 | "operationMode": operationMode, 586 | "source": source, 587 | "timestamp": str(int(time.time())), 588 | "version": version 589 | } 590 | _json = json.loads(self._requestHttp( 591 | urls.setApplianceCommand(appliance), _payload).text) 592 | 593 | if(_json["status"] != "OK"): 594 | raise Error(_json["message"]) 595 | 596 | def _getTranslation(self, applianceId, langKey): 597 | """ 598 | Getting translation based on selected languages 599 | """ 600 | 601 | if(langKey is None or langKey == ""): 602 | return "" 603 | _translation = None 604 | if(langKey in self._applianceTranslations[applianceId]): 605 | if(self._language == "All"): 606 | _translation = self._applianceTranslations[applianceId][langKey] 607 | elif(self._language in self._applianceTranslations[applianceId][langKey] 608 | and self._applianceTranslations[applianceId][langKey][self._language] != ""): 609 | _translation = self._applianceTranslations[applianceId][langKey][self._language] 610 | elif("eng" in self._applianceTranslations[applianceId][langKey]): 611 | _translation = self._applianceTranslations[applianceId][langKey]["eng"] 612 | return _translation 613 | 614 | def _requestHttp(self, operation, payload=None, verifySSL=None): 615 | """ 616 | Request to Electrolux cloud 617 | """ 618 | 619 | if (verifySSL is None): 620 | verifySSL = self._verifySsl 621 | 622 | _LOGGER.debug(f'URL: {operation[1]!s} {operation[0]!s}') 623 | if(payload): 624 | _LOGGER.debug(f"Request body: {str(payload).replace(self._password,'MaskedPassword').replace(self._username,'MaskedUsername')}") 625 | try: 626 | if (operation[1] == "GET"): 627 | response = requests.get(operation[0], 628 | headers=self._headers(), verify=verifySSL) 629 | elif (operation[1] == "POST"): 630 | response = requests.post(operation[0], json=payload, 631 | headers=self._headers(), verify=verifySSL) 632 | else: 633 | _LOGGER.error(f"Unsupported request definition: {operation[1]}") 634 | raise Error(f"Unsupported request definition: {operation[1]}") 635 | 636 | _LOGGER.debug(f"Respose body: {response.text}") 637 | 638 | if 2 != response.status_code // 100: 639 | raise ResponseError(response.status_code, response.text) 640 | 641 | except requests.exceptions.RequestException as ex: 642 | _LOGGER.error(f'Request error: {str(ex)}') 643 | raise RequestError(ex) 644 | 645 | _validate_response(response) 646 | return response 647 | 648 | def _findModel(self, applianceId): 649 | """ 650 | Find model on https://www.electrolux-ui.com/ website 651 | """ 652 | try: 653 | from bs4 import BeautifulSoup 654 | 655 | appliance = self._applianceIndex.get(applianceId) 656 | _LOGGER.info(f"Trying to get model {appliance['pnc']}_{appliance['elc']} info from https://www.electrolux-ui.com/ website") 657 | 658 | if(appliance): 659 | _html = self._requestHttp( 660 | urls.getDocsTable(appliance), verifySSL=True).text 661 | 662 | soup = BeautifulSoup(_html, "html.parser") 663 | cols = soup.find("table", {"class": "SearchGridView"}).find( 664 | "tr", {"class": "bottomBorder"}).find_all("td") 665 | if(cols[0].get_text().strip().startswith(f'{appliance["pnc"]}{appliance["elc"]}')): 666 | return cols[1].get_text().strip() 667 | else: 668 | return "" 669 | except Exception: 670 | return "" 671 | 672 | def login(self): 673 | """ 674 | Login to API 675 | """ 676 | if(os.path.exists(self._tokenFileName)): 677 | with open(self._tokenFileName, "r") as cookieFile: 678 | self._sessionToken = cookieFile.read().strip() 679 | 680 | _LOGGER.debug(f"Token file {self._tokenFileName} found") 681 | 682 | try: 683 | self._getAppliancesList() 684 | 685 | except ResponseError as ErrorArg: 686 | if(ErrorArg.status_code in ("ECP0105", "ECP0201")): 687 | _LOGGER.warning("Token probably expired, trying to get new one.") 688 | self._sessionToken = None 689 | os.remove(self._tokenFileName) 690 | else: 691 | _LOGGER.error(f"Error while get Appliances list: {ErrorArg.text}") 692 | raise Exception(ErrorArg.text) from None 693 | else: 694 | _LOGGER.debug(f"Token file {self._tokenFileName} not found") 695 | 696 | if(self._sessionToken is None): 697 | self._createToken() 698 | with open(self._tokenFileName, "w") as tokenFile: 699 | tokenFile.write(self._sessionToken) 700 | 701 | self._getAppliancesList() 702 | 703 | def getAppliances(self): 704 | """ 705 | Get user registered appliances 706 | """ 707 | if(self._sessionToken is None or 708 | self._applianceIndex is None): 709 | self.login() 710 | 711 | return self._applianceIndex 712 | 713 | def getApplianceConnectionState(self, 714 | applianceId): 715 | """ 716 | Get appliance connection state 717 | """ 718 | appliance = self._applianceIndex.get(applianceId) 719 | 720 | if(appliance): 721 | _json = json.loads(self._requestHttp( 722 | urls.getApplianceConnectionState(appliance)).text) 723 | 724 | if(_json["status"] == "OK"): 725 | return { 726 | "id": applianceId, 727 | "status": _json["data"][0]["stringValue"], 728 | "timestamp": _json["data"][0]["spkTimestamp"] 729 | } 730 | else: 731 | _LOGGER.error(f"Error while get appliance {applianceId} state: {_json['message']}") 732 | raise Exception(_json["message"]) 733 | 734 | return None 735 | 736 | def getApplianceState(self, 737 | applianceId, 738 | paramName=None, 739 | rawOutput=False): 740 | """ 741 | Get appliance latest state from Electrolux platform 742 | paramName - comma separated list of patrameter names (None for all params) 743 | rawOutput - False: parse output 744 | """ 745 | appliance = self._applianceIndex.get(applianceId) 746 | 747 | if(appliance): 748 | if(paramName): 749 | response = self._requestHttp( 750 | urls.getApplianceParameterState(appliance, paramName)) 751 | else: 752 | response = self._requestHttp( 753 | urls.getApplianceAllStates(appliance)) 754 | _json = json.loads(response.text) 755 | 756 | if(_json["status"] == "OK"): 757 | return self._parseApplianceState( 758 | _json["data"], applianceId, rawOutput=rawOutput) 759 | else: 760 | _LOGGER.error(f"Error while get appliance {applianceId} state: {_json['message']}") 761 | raise Exception(_json["message"]) 762 | return None 763 | 764 | def getApplianceProfile(self, 765 | applianceId): 766 | """ 767 | Get appliance profile (params used by appliance, supported hacl's, and Id's, with translations) 768 | """ 769 | if(self._applianceProfiles is None or 770 | self._applianceIndex is None): 771 | self.login() 772 | 773 | return self._applianceProfiles[applianceId] 774 | 775 | def setHacl(self, 776 | applianceId, 777 | hacl, 778 | haclValue, 779 | destination 780 | ): 781 | """ 782 | send hacl value to appliance 783 | hacl - parameter (hex format hacl - 0x0000) 784 | haclValue - value to set (for Container type, list of {Id: value} is required) 785 | destination - destination module name, from profile path (NIU, WD1, etc...) 786 | """ 787 | if(f'{destination}:{hacl}' not in self._applianceProfiles[applianceId]): 788 | _LOGGER.error(f'Unable to set HACL {hacl}: Unknown destination:hacl combination ({destination}:{hacl})') 789 | raise Exception( 790 | f'Unknown destination:hacl combination ({destination}:{hacl})') 791 | if(self._applianceProfiles[applianceId][f'{destination}:{hacl}']["access"] == "read"): 792 | _LOGGER.error(f"Unable to set HACL {hacl}: Parameter is read-only (based on profile file)") 793 | raise Exception("Read-Only parameter") 794 | if("container" in self._applianceProfiles[applianceId][f'{destination}:{hacl}']): 795 | if(not isinstance(haclValue, list)): 796 | _LOGGER.error(f"Unable to set HACL {hacl}: Container type must be list of dicts") 797 | raise Exception( 798 | "Container type hacl, value must be list of dicts") 799 | else: 800 | _paramsList = [{hacl: "Container"}] 801 | _paramsList.extend(haclValue) 802 | else: 803 | _paramsList = [{hacl: haclValue}] 804 | self._sendApplianceCommand( 805 | applianceId, 806 | _paramsList, 807 | destination 808 | ) 809 | 810 | def registerMQTT(self): 811 | """ 812 | Register device in Electrolux MQTT broker 813 | returns: 814 | Url - Host of MQTT broker (with port number) 815 | OrgId - Organization ID 816 | ClientId - MQTT Client ID 817 | DeviceToken - Token required to authentication 818 | for IBM broker, use 'use-token-auth' as username, 819 | DeviceToken as password 820 | 821 | """ 822 | _json = json.loads(self._requestHttp(urls.registerMQTT(), None).text) 823 | 824 | if(_json["status"] == "ERROR"): 825 | if(_json["code"] == "ECP0206"): 826 | """ Device registered already, unregister first to get new token """ 827 | _LOGGER.info(f"Device registered already in Electrolux MQTT broker, unregistering to get new token") 828 | self.unregisterMQTT() 829 | _json = json.loads(self._requestHttp( 830 | urls.registerMQTT(), None).text) 831 | else: 832 | _LOGGER.error(f"Error while register to Electrolux MQTT broker: {_json['message']}") 833 | raise Exception(_json["message"]) 834 | 835 | if(_json["status"] == "OK"): 836 | return { 837 | "Url": _json["data"]["MQTTURL"], 838 | "OrgId": _json["data"]["ECP_org_id"], 839 | "DeviceToken": _json["data"]["DeviceToken"], 840 | "ClientID": _json["data"]["ClientID"], 841 | } 842 | else: 843 | _LOGGER.error(f"Error while register to Electrolux MQTT broker: {_json['message']}") 844 | raise Exception(_json["message"]) 845 | 846 | def unregisterMQTT(self): 847 | """ 848 | Unregister device from Electrolux MQTT broker 849 | """ 850 | self._requestHttp(urls.unregisterMQTT(), None) 851 | --------------------------------------------------------------------------------