├── requirements.txt ├── pcomfortcloud.py ├── pcomfortcloud ├── exceptions.py ├── __init__.py ├── constants.py ├── session.py ├── __main__.py ├── apiclient.py └── authentication.py ├── LICENSE ├── setup.py ├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── README.md └── requests.http /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | urllib3 3 | bs4 4 | -------------------------------------------------------------------------------- /pcomfortcloud.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Command line interface for Panasonic Comfort Cloud """ 4 | from pcomfortcloud import __main__ 5 | __main__.main() 6 | -------------------------------------------------------------------------------- /pcomfortcloud/exceptions.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | pass 3 | 4 | 5 | class LoginError(Error): 6 | pass 7 | 8 | 9 | class RequestError(Error): 10 | pass 11 | 12 | 13 | class ResponseError(Error): 14 | pass 15 | -------------------------------------------------------------------------------- /pcomfortcloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A python module for reading and changing status of panasonic climate devices through Panasonic Comfort Cloud app api 3 | """ 4 | 5 | __all__ = [ 6 | 'ApiClient', 7 | 'Error', 8 | 'LoginError', 9 | 'RequestError', 10 | 'ResponseError' 11 | ] 12 | 13 | from .apiclient import ( 14 | ApiClient 15 | ) 16 | 17 | from .session import ( 18 | Session 19 | ) 20 | 21 | from .authentication import ( 22 | Authentication 23 | ) 24 | 25 | from .exceptions import ( 26 | Error, 27 | LoginError, 28 | RequestError, 29 | ResponseError 30 | ) 31 | 32 | from . import constants 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ Setup for python-panasonic-comfort-cloud """ 2 | 3 | from setuptools import setup 4 | import os 5 | 6 | with open("README.md", "r") as fh: 7 | long_description = fh.read() 8 | 9 | setup( 10 | name='pcomfortcloud', 11 | version=os.getenv('VERSION', default='0.0.1'), 12 | description='Read and change status of Panasonic Comfort Cloud devices', 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url='http://github.com/lostfields/python-panasonic-comfort-cloud', 16 | author='Lostfields', 17 | license='MIT', 18 | classifiers=[ 19 | 'Topic :: Home Automation', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Programming Language :: Python :: 3.4', 22 | 'Programming Language :: Python :: 3.5', 23 | ], 24 | keywords='home automation panasonic climate', 25 | install_requires=['requests>=2.20.0'], 26 | packages=['pcomfortcloud'], 27 | package_data={'': ['certificatechain.pem']}, 28 | zip_safe=False, 29 | entry_points={ 30 | 'console_scripts': [ 31 | 'pcomfortcloud=pcomfortcloud.__main__:main', 32 | ] 33 | }) 34 | -------------------------------------------------------------------------------- /pcomfortcloud/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | APP_CLIENT_ID = "Xmy6xIYIitMxngjB2rHvlm6HSDNnaMJx" 4 | AUTH_0_CLIENT = "eyJuYW1lIjoiQXV0aDAuQW5kcm9pZCIsImVudiI6eyJhbmRyb2lkIjoiMzAifSwidmVyc2lvbiI6IjIuOS4zIn0=" 5 | REDIRECT_URI = "panasonic-iot-cfc://authglb.digital.panasonic.com/android/com.panasonic.ACCsmart/callback" 6 | BASE_PATH_AUTH = "https://authglb.digital.panasonic.com" 7 | BASE_PATH_ACC = "https://accsmart.panasonic.com" 8 | X_APP_VERSION = "1.22.0" 9 | 10 | class Power(Enum): 11 | Off = 0 12 | On = 1 13 | 14 | class OperationMode(Enum): 15 | Auto = 0 16 | Dry = 1 17 | Cool = 2 18 | Heat = 3 19 | Fan = 4 20 | 21 | class AirSwingUD(Enum): 22 | Auto = -1 23 | Up = 0 24 | UpMid = 3 25 | Mid = 2 26 | DownMid = 4 27 | Down = 1 28 | Swing = 5 29 | 30 | class AirSwingLR(Enum): 31 | Auto = -1 32 | Left = 1 33 | LeftMid = 5 34 | Mid = 2 35 | RightMid = 4 36 | Right = 0 37 | 38 | class EcoMode(Enum): 39 | Auto = 0 40 | Powerful = 1 41 | Quiet = 2 42 | 43 | class AirSwingAutoMode(Enum): 44 | Disabled = 1 45 | Both = 0 46 | AirSwingLR = 3 47 | AirSwingUD = 2 48 | 49 | class FanSpeed(Enum): 50 | Auto = 0 51 | Low = 1 52 | LowMid = 2 53 | Mid = 3 54 | HighMid = 4 55 | High = 5 56 | 57 | class DataMode(Enum): 58 | Day = 0 59 | Week = 1 60 | Month = 2 61 | Year = 4 62 | 63 | class NanoeMode(Enum): 64 | Unavailable = 0 65 | Off = 1 66 | On = 2 67 | ModeG = 3 68 | All = 4 69 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - id: setup 22 | name: setup 23 | run: 24 | echo "::set-output name=version::$( echo $GITHUB_REF_NAME | sed 's/v\?\(.*\)/\1/g' )" 25 | 26 | - uses: actions/checkout@v4 27 | 28 | - name: Set up Python 3.7 29 | uses: actions/setup-python@v1 30 | with: 31 | python-version: 3.7 32 | 33 | - name: Install pypa/build 34 | run: >- 35 | python -m 36 | pip install 37 | build 38 | --user 39 | 40 | - name: Build a binary wheel and a source tarball 41 | run: >- 42 | python -m 43 | build 44 | --sdist 45 | --wheel 46 | --outdir dist/ 47 | . 48 | env: 49 | VERSION: ${{ steps.setup.outputs.version }} 50 | 51 | - name: upload artifact 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: artifact 55 | path: dist 56 | retention-days: 7 57 | 58 | - name: Publish distribution to PyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | with: 61 | password: ${{ secrets.PYPI_API_TOKEN }} 62 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .vscode 107 | .idea 108 | 109 | token.json 110 | -------------------------------------------------------------------------------- /pcomfortcloud/session.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import Path 4 | 5 | from .authentication import Authentication 6 | from .apiclient import ApiClient 7 | from . import constants 8 | 9 | class Session(Authentication): 10 | """ Verisure app session 11 | 12 | Args: 13 | username (str): Username used to login to verisure app 14 | password (str): Password used to login to verisure app 15 | 16 | """ 17 | 18 | def __init__(self, username, password, tokenFileName='$HOME/.panasonic-oauth-token', raw=False): 19 | super().__init__(username, password, None, raw) 20 | 21 | home = str(Path.home()) 22 | self._tokenFileName = os.path.expanduser(tokenFileName.replace("$HOME", home)) 23 | self._api = ApiClient(self, raw) 24 | 25 | def login(self): 26 | if super().is_token_valid() is True: 27 | return 28 | 29 | if super().get_token() is None and os.path.exists(self._tokenFileName): 30 | with open(self._tokenFileName, "r") as tokenFile: 31 | self.token = json.load(tokenFile) 32 | 33 | if self._raw: print("--- token read") 34 | super().set_token(self.token) 35 | 36 | state = super().login() 37 | 38 | if self._raw: print("--- authentication state: " + state) 39 | 40 | if state != "Valid": 41 | self.token = super().get_token() 42 | 43 | with open(self._tokenFileName, "w") as tokenFile: 44 | json.dump(self.token, tokenFile, indent=4) 45 | 46 | if self._raw: print("--- token written") 47 | 48 | def logout(self): 49 | super().logout() 50 | 51 | def execute_post(self, 52 | url, 53 | json_data, 54 | function_description, 55 | expected_status_code): 56 | return super().execute_post(url, json_data, function_description, expected_status_code) 57 | 58 | def execute_get(self, url, function_description, expected_status_code): 59 | return super().execute_get(url, function_description, expected_status_code) 60 | 61 | def get_devices(self, group=None): 62 | return self._api.get_devices() 63 | 64 | def dump(self, id): 65 | return self._api.dump(id) 66 | 67 | def history(self, id, mode, date, tz="+01:00"): 68 | return self._api.history(id, mode, date, tz) 69 | 70 | def get_device(self, id): 71 | return self._api.get_device(id) 72 | 73 | def set_device(self, id, **kwargs): 74 | return self._api.set_device(id, **kwargs) 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-panasonic-comfort-cloud 2 | A python module for reading and changing status of panasonic climate devices through Panasonic Comfort Cloud app api 3 | 4 | ## Command line usage 5 | 6 | ``` 7 | usage: pcomfortcloud.py [-h] [-t TOKEN] username password {list,get,set} ... 8 | 9 | Read or change status of Panasonic Climate devices 10 | 11 | positional arguments: 12 | username Username for Panasonic Comfort Cloud 13 | password Password for Panasonic Comfort Cloud 14 | {list,get,set,dump} commands 15 | list Get a list of all devices 16 | get Get status of a device 17 | set Set status of a device 18 | dump Dump raw data of a device 19 | history Dump history of a device 20 | 21 | optional arguments: 22 | -h, --help show this help message and exit 23 | -t TOKEN, --token TOKEN 24 | File to store token in 25 | -s [BOOL], --skipVerify [BOOL] 26 | Skip Ssl verification 27 | -r [BOOL], --raw [BOOL] 28 | Raw dump of response 29 | ``` 30 | 31 | ``` 32 | usage: pcomfortcloud.py username password get [-h] device 33 | 34 | positional arguments: 35 | device device number 36 | 37 | optional arguments: 38 | -h, --help show this help message and exit 39 | ``` 40 | 41 | ``` 42 | usage: pcomfortcloud.py username password set [-h] 43 | [-p, --power {On,Off}] 44 | [-t, --temperature TEMPERATURE] 45 | [-f, --fanspeed {Auto,Low,LowMid,Mid,HighMid,High}] 46 | [-m, --mode {Auto,Cool,Dry,Heat,Fan}] 47 | [-e, --eco {Auto,Quiet,Powerful}] 48 | [-y, --airswingvertical {Auto,Down,DownMid,Mid,UpMid,Up}] 49 | [-x, --airswinghorizontal {Auto,Left,LeftMid,Mid,RightMid,Right}] 50 | device 51 | 52 | positional arguments: 53 | device Device number 54 | 55 | optional arguments: 56 | -h, --help 57 | show this help message and exit 58 | -p, --power {On,Off} 59 | Power mode 60 | -t, --temperature TEMPERATURE 61 | Temperature in decimal format 62 | -f, --fanspeed {Auto,Low,LowMid,Mid,HighMid,High} 63 | Fan speed 64 | -m, --mode {Auto,Cool,Dry,Heat,Fan} 65 | Operation mode 66 | -e, --eco {Auto,Quiet,Powerful} 67 | Eco mode 68 | -y, --airswingvertical {Auto,Down,DownMid,Mid,UpMid,Up} 69 | Vertical position of the air swing 70 | -x, --airswinghorizontal {Auto,Left,LeftMid,Mid,RightMid,Right} 71 | Horizontal position of the air swing 72 | ``` 73 | 74 | ``` 75 | usage: pcomfortcloud username password dump [-h] device 76 | 77 | positional arguments: 78 | device Device number 1-x 79 | 80 | optional arguments: 81 | -h, --help show this help message and exit 82 | ``` 83 | 84 | ``` 85 | usage: pcomfortcloud username password history [-h] device mode date 86 | 87 | positional arguments: 88 | device Device number 1-x 89 | mode mode (Day, Week, Month, Year) 90 | date date of day like 20190807 91 | 92 | optional arguments: 93 | -h, --help show this help message and exit 94 | ``` 95 | 96 | ## Module usage 97 | 98 | 99 | ```python 100 | import pcomfortcloud 101 | 102 | 103 | session = pcomfortcloud.Session('user@example.com', 'mypassword') 104 | session.login() 105 | 106 | client = pcomfortcloud.ApiClient(session) 107 | 108 | devices = client.get_devices() 109 | 110 | print(devices) 111 | 112 | print(client.get_device(devices[0]['id'])) 113 | 114 | client.set_device(devices[0]['id'], 115 | power = pcomfortcloud.constants.Power.On, 116 | temperature = 22.0) 117 | ``` 118 | 119 | ## PyPi package 120 | can be found at https://pypi.org/project/pcomfortcloud/ 121 | 122 | ### How to publish package; 123 | - `python .\setup.py sdist bdist_wheel` 124 | - `python -m twine upload dist/*` 125 | -------------------------------------------------------------------------------- /requests.http: -------------------------------------------------------------------------------- 1 | @APP-VERSION = 1.20.1 2 | 3 | ### 4 | 5 | # @name login 6 | POST https://accsmart.panasonic.com/auth/login HTTP/1.1 7 | X-APP-TYPE: 1 8 | X-APP-VERSION: {{APP-VERSION}} 9 | User-Agent: G-RAC 10 | X-APP-TIMESTAMP: 1 11 | X-APP-NAME: Comfort Cloud 12 | X-CFC-API-KEY: Comfort Cloud 13 | Accept: application/json; charset=utf-8 14 | Content-Type: application/json; charset=utf-8 15 | 16 | { 17 | "language": 0, 18 | "loginId": "{{$dotenv USERNAME}}", 19 | "password": "{{$dotenv PASSWORD}}" 20 | } 21 | 22 | ### 23 | # @name device 24 | GET https://accsmart.panasonic.com/device/group HTTP/1.1 25 | X-User-Authorization: {{login.response.body.$.uToken}} 26 | X-APP-TYPE: 1 27 | X-APP-VERSION: {{APP-VERSION}} 28 | X-APP-TIMESTAMP: 1 29 | X-APP-NAME: Comfort Cloud 30 | X-CFC-API-KEY: Comfort Cloud 31 | User-Agent: G-RAC 32 | Accept: application/json; charset=utf-8 33 | Content-Type: application/json; charset=utf-8 34 | 35 | ### 36 | 37 | GET https://accsmart.panasonic.com/deviceStatus/now/{{device.response.body.$.groupList[0].deviceList[0].deviceGuid}} HTTP/1.1 38 | X-User-Authorization: {{login.response.body.$.uToken}} 39 | X-APP-TYPE: 1 40 | X-APP-VERSION: {{APP-VERSION}} 41 | X-APP-TIMESTAMP: 1 42 | X-APP-NAME: Comfort Cloud 43 | X-CFC-API-KEY: Comfort Cloud 44 | User-Agent: G-RAC 45 | Accept: application/json; charset=utf-8 46 | Content-Type: application/json; charset=utf-8 47 | 48 | ### 49 | 50 | GET https://accsmart.panasonic.com/deviceStatus/{{device.response.body.$.groupList[0].deviceList[0].deviceGuid}} HTTP/1.1 51 | X-User-Authorization: {{login.response.body.$.uToken}} 52 | X-APP-TYPE: 1 53 | X-APP-VERSION: {{APP-VERSION}} 54 | X-APP-TIMESTAMP: 1 55 | X-APP-NAME: Comfort Cloud 56 | X-CFC-API-KEY: Comfort Cloud 57 | User-Agent: G-RAC 58 | Accept: application/json; charset=utf-8 59 | Content-Type: application/json; charset=utf-8 60 | 61 | 62 | ### 63 | 64 | POST https://accsmart.panasonic.com/deviceHistoryData HTTP/1.1 65 | X-User-Authorization: {{login.response.body.$.uToken}} 66 | X-APP-TYPE: 1 67 | X-APP-VERSION: {{APP-VERSION}} 68 | X-APP-TIMESTAMP: 1 69 | X-APP-NAME: Comfort Cloud 70 | X-CFC-API-KEY: Comfort Cloud 71 | User-Agent: G-RAC 72 | Accept: application/json; charset=utf-8 73 | Content-Type: application/json; charset=utf-8 74 | 75 | { 76 | "dataMode": 0, "date": "20190610", "deviceGuid": "{{device.response.body.$.groupList[0].deviceList[0].deviceGuid}}", "osTimezone": "+01:00" 77 | } 78 | 79 | ### 80 | 81 | POST https://accsmart.panasonic.com/deviceStatus/control HTTP/1.1 82 | X-User-Authorization: {{login.response.body.$.uToken}} 83 | X-APP-TYPE: 1 84 | X-APP-VERSION: {{APP-VERSION}} 85 | X-APP-TIMESTAMP: 1 86 | X-APP-NAME: Comfort Cloud 87 | X-CFC-API-KEY: Comfort Cloud 88 | User-Agent: G-RAC 89 | Accept: application/json; charset=utf-8 90 | Content-Type: application/json; charset=utf-8 91 | 92 | { 93 | "deviceGuid": "{{device.response.body.$.groupList[0].deviceList[0].deviceGuid}}", 94 | "parameters": { 95 | "operate": 1, 96 | "operationMode": 3, 97 | "ecoMode": null, 98 | "temperatureSet": 22.5, 99 | "airSwingUD": null, 100 | "airSwingLR": null, 101 | "fanAutoMode": null, 102 | "fanSpeed": null 103 | } 104 | } 105 | 106 | ### 107 | POST https://accsmart.panasonic.com/deviceStatus/control HTTP/1.1 108 | X-User-Authorization: {{login.response.body.$.uToken}} 109 | X-APP-TYPE: 1 110 | X-APP-VERSION: {{APP-VERSION}} 111 | X-APP-TIMESTAMP: 1 112 | X-APP-NAME: Comfort Cloud 113 | X-CFC-API-KEY: Comfort Cloud 114 | User-Agent: G-RAC 115 | Accept: application/json 116 | Content-Type: application/json 117 | 118 | { 119 | "deviceGuid": "{{device.response.body.$.groupList[0].deviceList[0].deviceGuid}}", 120 | "parameters": { 121 | "temperatureSet": 21.0 122 | } 123 | } 124 | 125 | ### 126 | GET https://accsmart.panasonic.com/auth/agreement/status/1 HTTP/1.1 127 | X-User-Authorization: {{login.response.body.$.uToken}} 128 | X-APP-TYPE: 1 129 | X-APP-VERSION: {{APP-VERSION}} 130 | Accept: application/json; charset=utf-8 131 | Content-Type: application/json 132 | X-APP-TIMESTAMP: 1 133 | X-APP-NAME: Comfort Cloud 134 | X-CFC-API-KEY: Comfort Cloud 135 | User-Agent: G-RAC 136 | Host: accsmart.panasonic.com 137 | Connection: Keep-Alive 138 | Accept-Encoding: gzip 139 | 140 | ### 141 | GET https://accsmart.panasonic.com/auth/agreement/documents/0/1 HTTP/1.1 142 | X-User-Authorization: {{login.response.body.$.uToken}} 143 | X-APP-TYPE: 1 144 | X-APP-VERSION: {{APP-VERSION}} 145 | Accept: application/json; charset=utf-8 146 | Content-Type: application/json 147 | User-Agent: G-RAC 148 | Host: accsmart.panasonic.com 149 | Connection: Keep-Alive 150 | Accept-Encoding: gzip 151 | 152 | ### 153 | GET https://accsmart.panasonic.com/auth/agreement/status/ HTTP/1.1 154 | X-User-Authorization: {{login.response.body.$.uToken}} 155 | X-APP-TYPE: 1 156 | X-APP-VERSION: {{APP-VERSION}} 157 | User-Agent: G-RAC 158 | Accept: application/json; charset=utf-8 159 | Content-Type: application/json; charset=utf-8 160 | 161 | ### 162 | # type may be 0,1,2 163 | PUT https://accsmart.panasonic.com/auth/agreement/status/ HTTP/1.1 164 | X-User-Authorization: {{login.response.body.$.uToken}} 165 | X-APP-TYPE: 1 166 | X-APP-VERSION: {{APP-VERSION}} 167 | User-Agent: G-RAC 168 | Accept: application/json 169 | Content-Type: application/json 170 | 171 | { "agreementStatus": 0, "type": 0 } 172 | 173 | ### 174 | 175 | "airSwingUD": { 176 | "Up": 0, 177 | "Down": 1, 178 | "Mid": 2, 179 | "UpMid": 3, 180 | "DownMid": 4 181 | } 182 | 183 | "airSwingLR": { 184 | "Left": 0, 185 | "Right": 1, 186 | "Mid": 2, 187 | "RightMid": 3, 188 | "LeftMid": 4 189 | } 190 | 191 | "ecoMode": { 192 | "Auto": 0, 193 | "Powerful": 1, 194 | "Quiet": 2 195 | } 196 | 197 | "fanAutoMode": { 198 | "Disabled": 1, 199 | "AirSwingAuto": 0, 200 | "AirSwingLR": 3, 201 | "AirSwingUD": 2 202 | } 203 | 204 | "fanSpeed": { 205 | "Auto": 0, 206 | "Low": 1, 207 | "LowMid": 2, 208 | "Mid": 3, 209 | "HighMid": 4, 210 | "High": 5, 211 | } 212 | 213 | "operate": { 214 | "Off": 0, 215 | "On": 1 216 | } 217 | 218 | "operationMode": { 219 | "Auto": 0, 220 | "Dry": 1, 221 | "Cool": 2, 222 | "Heat": 3, 223 | "Fan": 4 // not sure 224 | } -------------------------------------------------------------------------------- /pcomfortcloud/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import json 4 | import pcomfortcloud 5 | 6 | from enum import Enum 7 | 8 | def print_result(obj, indent=0): 9 | for key in obj: 10 | value = obj[key] 11 | 12 | if isinstance(value, dict): 13 | print(" "*indent + key) 14 | print_result(value, indent + 4) 15 | elif isinstance(value, Enum): 16 | print( 17 | " "*indent + "{0: <{width}}: {1}".format(key, value.name, width=25-indent)) 18 | elif isinstance(value, list): 19 | print(" "*indent + "{0: <{width}}:".format(key, width=25-indent)) 20 | for elt in value: 21 | print_result(elt, indent + 4) 22 | print("") 23 | else: 24 | print(" "*indent + 25 | "{0: <{width}}: {1}".format(key, value, width=25-indent)) 26 | 27 | 28 | def str2bool(boolean_string_value): 29 | if boolean_string_value.lower() in ('yes', 'true', 't', 'y', '1'): 30 | return True 31 | if boolean_string_value.lower() in ('no', 'false', 'f', 'n', '0'): 32 | return False 33 | raise argparse.ArgumentTypeError('Boolean value expected.') 34 | 35 | 36 | def main(): 37 | """ Start pcomfortcloud Comfort Cloud command line """ 38 | 39 | parser = argparse.ArgumentParser( 40 | description='Read or change status of pcomfortcloud Climate devices') 41 | 42 | parser.add_argument( 43 | 'username', 44 | help='Username for pcomfortcloud Comfort Cloud') 45 | 46 | parser.add_argument( 47 | 'password', 48 | help='Password for pcomfortcloud Comfort Cloud') 49 | 50 | parser.add_argument( 51 | '-t', '--token', 52 | help='File to store token in', 53 | default='$HOME/.pcomfortcloud-oauth-token') 54 | 55 | parser.add_argument( 56 | '-r', '--raw', 57 | help='Raw dump of response', 58 | type=str2bool, nargs='?', const=True, 59 | default=False) 60 | 61 | commandparser = parser.add_subparsers( 62 | help='commands', 63 | dest='command') 64 | 65 | commandparser.add_parser( 66 | 'list', 67 | help="Get a list of all devices") 68 | 69 | get_parser = commandparser.add_parser( 70 | 'get', 71 | help="Get status of a device") 72 | 73 | get_parser.add_argument( 74 | dest='device', 75 | type=int, 76 | help='Device number #') 77 | 78 | set_parser = commandparser.add_parser( 79 | 'set', 80 | help="Set status of a device") 81 | 82 | set_parser.add_argument( 83 | dest='device', 84 | type=int, 85 | help='Device number #' 86 | ) 87 | 88 | set_parser.add_argument( 89 | '-p', '--power', 90 | choices=[ 91 | pcomfortcloud.constants.Power.On.name, 92 | pcomfortcloud.constants.Power.Off.name], 93 | help='Power mode') 94 | 95 | set_parser.add_argument( 96 | '-t', '--temperature', 97 | type=float, 98 | help="Temperature") 99 | 100 | set_parser.add_argument( 101 | '-f', '--fanSpeed', 102 | choices=[ 103 | pcomfortcloud.constants.FanSpeed.Auto.name, 104 | pcomfortcloud.constants.FanSpeed.Low.name, 105 | pcomfortcloud.constants.FanSpeed.LowMid.name, 106 | pcomfortcloud.constants.FanSpeed.Mid.name, 107 | pcomfortcloud.constants.FanSpeed.HighMid.name, 108 | pcomfortcloud.constants.FanSpeed.High.name], 109 | help='Fan speed') 110 | 111 | set_parser.add_argument( 112 | '-m', '--mode', 113 | choices=[ 114 | pcomfortcloud.constants.OperationMode.Auto.name, 115 | pcomfortcloud.constants.OperationMode.Cool.name, 116 | pcomfortcloud.constants.OperationMode.Dry.name, 117 | pcomfortcloud.constants.OperationMode.Heat.name, 118 | pcomfortcloud.constants.OperationMode.Fan.name], 119 | help='Operation mode') 120 | 121 | set_parser.add_argument( 122 | '-e', '--eco', 123 | choices=[ 124 | pcomfortcloud.constants.EcoMode.Auto.name, 125 | pcomfortcloud.constants.EcoMode.Quiet.name, 126 | pcomfortcloud.constants.EcoMode.Powerful.name], 127 | help='Eco mode') 128 | 129 | set_parser.add_argument( 130 | '-n', '--nanoe', 131 | choices=[ 132 | pcomfortcloud.constants.NanoeMode.On.name, 133 | pcomfortcloud.constants.NanoeMode.Off.name, 134 | pcomfortcloud.constants.NanoeMode.ModeG.name, 135 | pcomfortcloud.constants.NanoeMode.All.name], 136 | help='Nanoe mode') 137 | 138 | # set_parser.add_argument( 139 | # '--airswingauto', 140 | # choices=[ 141 | # pcomfortcloud.constants.AirSwingAutoMode.Disabled.name, 142 | # pcomfortcloud.constants.AirSwingAutoMode.AirSwingLR.name, 143 | # pcomfortcloud.constants.AirSwingAutoMode.AirSwingUD.name, 144 | # pcomfortcloud.constants.AirSwingAutoMode.Both.name], 145 | # help='Automation of air swing') 146 | 147 | set_parser.add_argument( 148 | '-y', '--airSwingVertical', 149 | choices=[ 150 | pcomfortcloud.constants.AirSwingUD.Auto.name, 151 | pcomfortcloud.constants.AirSwingUD.Down.name, 152 | pcomfortcloud.constants.AirSwingUD.DownMid.name, 153 | pcomfortcloud.constants.AirSwingUD.Mid.name, 154 | pcomfortcloud.constants.AirSwingUD.UpMid.name, 155 | pcomfortcloud.constants.AirSwingUD.Up.name], 156 | help='Vertical position of the air swing') 157 | 158 | set_parser.add_argument( 159 | '-x', '--airSwingHorizontal', 160 | choices=[ 161 | pcomfortcloud.constants.AirSwingLR.Auto.name, 162 | pcomfortcloud.constants.AirSwingLR.Left.name, 163 | pcomfortcloud.constants.AirSwingLR.LeftMid.name, 164 | pcomfortcloud.constants.AirSwingLR.Mid.name, 165 | pcomfortcloud.constants.AirSwingLR.RightMid.name, 166 | pcomfortcloud.constants.AirSwingLR.Right.name], 167 | help='Horizontal position of the air swing') 168 | 169 | dump_parser = commandparser.add_parser( 170 | 'dump', 171 | help="Dump data of a device") 172 | 173 | dump_parser.add_argument( 174 | dest='device', 175 | type=int, 176 | help='Device number 1-x') 177 | 178 | history_parser = commandparser.add_parser( 179 | 'history', 180 | help="Dump history of a device") 181 | 182 | history_parser.add_argument( 183 | dest='device', 184 | type=int, 185 | help='Device number 1-x') 186 | 187 | history_parser.add_argument( 188 | dest='mode', 189 | type=str, 190 | help='mode (Day, Week, Month, Year)') 191 | 192 | history_parser.add_argument( 193 | dest='date', 194 | type=str, 195 | help='date of day like 20190807') 196 | 197 | args = parser.parse_args() 198 | 199 | session = pcomfortcloud.Session(args.username, args.password, args.token, args.raw) 200 | session.login() 201 | try: 202 | if args.command == 'list': 203 | print("list of devices and its device id (1-x)") 204 | for idx, device in enumerate(session.get_devices()): 205 | if idx > 0: 206 | print('') 207 | 208 | print("device #{}".format(idx + 1)) 209 | print_result(device, 4) 210 | 211 | if args.command == 'get': 212 | if int(args.device) <= 0 or int(args.device) > len(session.get_devices()): 213 | raise Exception("device not found, acceptable device id is from {} to {}".format(1, len(session.get_devices()))) 214 | 215 | device = session.get_devices()[int(args.device) - 1] 216 | print("reading from device '{}' ({})".format(device['name'], device['id'])) 217 | 218 | print_result( session.get_device(device['id']) ) 219 | 220 | if args.command == 'set': 221 | if int(args.device) <= 0 or int(args.device) > len(session.get_devices()): 222 | raise Exception("device not found, acceptable device id is from {} to {}".format(1, len(session.get_devices()))) 223 | 224 | device = session.get_devices()[int(args.device) - 1] 225 | print("writing to device '{}' ({})".format(device['name'], device['id'])) 226 | 227 | kwargs = {} 228 | 229 | if args.power is not None: 230 | kwargs['power'] = pcomfortcloud.constants.Power[args.power] 231 | 232 | if args.temperature is not None: 233 | kwargs['temperature'] = args.temperature 234 | 235 | if args.fanSpeed is not None: 236 | kwargs['fanSpeed'] = pcomfortcloud.constants.FanSpeed[args.fanSpeed] 237 | 238 | if args.mode is not None: 239 | kwargs['mode'] = pcomfortcloud.constants.OperationMode[args.mode] 240 | 241 | if args.eco is not None: 242 | kwargs['eco'] = pcomfortcloud.constants.EcoMode[args.eco] 243 | 244 | if args.nanoe is not None: 245 | kwargs['nanoe'] = pcomfortcloud.constants.NanoeMode[args.nanoe] 246 | 247 | if args.airSwingHorizontal is not None: 248 | kwargs['airSwingHorizontal'] = pcomfortcloud.constants.AirSwingLR[args.airSwingHorizontal] 249 | 250 | if args.airSwingVertical is not None: 251 | kwargs['airSwingVertical'] = pcomfortcloud.constants.AirSwingUD[args.airSwingVertical] 252 | 253 | session.set_device(device['id'], **kwargs) 254 | 255 | if args.command == 'dump': 256 | if int(args.device) <= 0 or int(args.device) > len(session.get_devices()): 257 | raise Exception("device not found, acceptable device id is from {} to {}".format(1, len(session.get_devices()))) 258 | 259 | device = session.get_devices()[int(args.device) - 1] 260 | 261 | print_result(session.dump(device['id'])) 262 | 263 | if args.command == 'history': 264 | if int(args.device) <= 0 or int(args.device) > len(session.get_devices()): 265 | raise Exception("device not found, acceptable device id is from {} to {}".format(1, len(session.get_devices()))) 266 | 267 | device = session.get_devices()[int(args.device) - 1] 268 | 269 | print_result(session.history(device['id'], args.mode, args.date)) 270 | 271 | except pcomfortcloud.ResponseError as ex: 272 | print(ex) 273 | 274 | 275 | if __name__ == "__main__": 276 | main() 277 | -------------------------------------------------------------------------------- /pcomfortcloud/apiclient.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Panasonic session, using Panasonic Comfort Cloud app api 3 | ''' 4 | 5 | import hashlib 6 | import re 7 | from urllib.parse import quote_plus 8 | 9 | from . import authentication 10 | from . import constants 11 | 12 | 13 | class ApiClient(): 14 | def __init__(self, auth: authentication.Authentication, raw=False): 15 | self._auth = auth 16 | 17 | self._groups = None 18 | self._devices = None 19 | self._device_indexer = {} 20 | self._raw = raw 21 | self._acc_client_id = None 22 | 23 | def _ensure_logged_in(self): 24 | self._auth.login() 25 | 26 | def _get_groups(self): 27 | self._ensure_logged_in() 28 | self._groups = self._auth.execute_get( 29 | self._get_group_url(), 30 | "get_groups", 31 | 200 32 | ) 33 | self._devices = None 34 | 35 | def get_devices(self): 36 | if self._devices is None: 37 | if self._groups is None: 38 | self._get_groups() 39 | 40 | self._devices = [] 41 | 42 | for group in self._groups['groupList']: 43 | if 'deviceList' in group: 44 | device_list = group.get('deviceList', []) 45 | else: 46 | device_list = group.get('deviceIdList', []) 47 | 48 | for device in device_list: 49 | if device: 50 | if 'deviceHashGuid' in device: 51 | device_id = device['deviceHashGuid'] 52 | else: 53 | device_id = hashlib.md5( 54 | device['deviceGuid'].encode('utf-8')).hexdigest() 55 | 56 | self._device_indexer[device_id] = device['deviceGuid'] 57 | self._devices.append({ 58 | 'id': device_id, 59 | 'name': device['deviceName'], 60 | 'group': group['groupName'], 61 | 'model': device['deviceModuleNumber'] if 'deviceModuleNumber' in device else '' 62 | }) 63 | return self._devices 64 | 65 | def dump(self, device_id): 66 | device_guid = self._device_indexer.get(device_id) 67 | if device_guid: 68 | return self._auth.execute_get(self._get_device_status_url(device_guid), "dump", 200) 69 | return None 70 | 71 | def history(self, device_id, mode, date, time_zone="+01:00"): 72 | self._ensure_logged_in() 73 | 74 | device_guid = self._device_indexer.get(device_id) 75 | 76 | if device_guid: 77 | try: 78 | data_mode = constants.DataMode[mode].value 79 | except KeyError: 80 | raise Exception("Wrong mode parameter") 81 | 82 | payload = { 83 | "deviceGuid": device_guid, 84 | "dataMode": data_mode, 85 | "date": date, 86 | "osTimezone": time_zone 87 | } 88 | 89 | json_response = self._auth.execute_post( 90 | self._get_device_history_url(), payload, "history", 200) 91 | 92 | return { 93 | 'id': device_id, 94 | 'parameters': self._read_parameters(json_response) 95 | } 96 | return None 97 | 98 | def get_device(self, device_id): 99 | self._ensure_logged_in() 100 | 101 | device_guid = self._device_indexer.get(device_id) 102 | 103 | if device_guid: 104 | json_response = self._auth.execute_get( 105 | self._get_device_status_url(device_guid), "get_device", 200) 106 | return { 107 | 'id': device_id, 108 | 'parameters': self._read_parameters(json_response['parameters']) 109 | } 110 | return None 111 | 112 | def set_device(self, device_id, **kwargs): 113 | """ Set parameters of device 114 | 115 | Args: 116 | device_id (str): Id of the device 117 | kwargs : {temperature=float}, {mode=OperationMode}, {fanSpeed=FanSpeed}, {power=Power}, 118 | {airSwingHorizontal=}, {airSwingVertical=}, {eco=EcoMode} 119 | """ 120 | 121 | parameters = {} 122 | air_x = None 123 | air_y = None 124 | 125 | if kwargs is not None: 126 | for key, value in kwargs.items(): 127 | if key == 'power' and isinstance(value, constants.Power): 128 | parameters['operate'] = value.value 129 | 130 | if key == 'temperature': 131 | parameters['temperatureSet'] = value 132 | 133 | if key == 'mode' and isinstance(value, constants.OperationMode): 134 | parameters['operationMode'] = value.value 135 | 136 | if key == 'fanSpeed' and isinstance(value, constants.FanSpeed): 137 | parameters['fanSpeed'] = value.value 138 | 139 | if key == 'airSwingHorizontal' and isinstance(value, constants.AirSwingLR): 140 | air_x = value 141 | 142 | if key == 'airSwingVertical' and isinstance(value, constants.AirSwingUD): 143 | air_y = value 144 | 145 | if key == 'eco' and isinstance(value, constants.EcoMode): 146 | parameters['ecoMode'] = value.value 147 | 148 | if key == 'nanoe' and \ 149 | isinstance(value, constants.NanoeMode) and \ 150 | value != constants.NanoeMode.Unavailable: 151 | parameters['nanoe'] = value.value 152 | 153 | # routine to set the auto mode of fan 154 | # (either horizontal, vertical, both or disabled) 155 | if air_x is not None or air_y is not None: 156 | fan_auto = 0 157 | device = self.get_device(device_id) 158 | 159 | if device and device['parameters']['airSwingHorizontal'].value == -1: 160 | fan_auto = fan_auto | 1 161 | 162 | if device and device['parameters']['airSwingVertical'].value == -1: 163 | fan_auto = fan_auto | 2 164 | 165 | if air_x is not None: 166 | if air_x.value == -1: 167 | fan_auto = fan_auto | 1 168 | else: 169 | fan_auto = fan_auto & ~1 170 | parameters['airSwingLR'] = air_x.value 171 | 172 | if air_y is not None: 173 | if air_y.value == -1: 174 | fan_auto = fan_auto | 2 175 | else: 176 | fan_auto = fan_auto & ~2 177 | parameters['airSwingUD'] = air_y.value 178 | 179 | if fan_auto == 3: 180 | parameters['fanAutoMode'] = constants.AirSwingAutoMode.Both.value 181 | elif fan_auto == 1: 182 | parameters['fanAutoMode'] = constants.AirSwingAutoMode.AirSwingLR.value 183 | elif fan_auto == 2: 184 | parameters['fanAutoMode'] = constants.AirSwingAutoMode.AirSwingUD.value 185 | else: 186 | parameters['fanAutoMode'] = constants.AirSwingAutoMode.Disabled.value 187 | 188 | device_guid = self._device_indexer.get(device_id) 189 | if device_guid: 190 | payload = { 191 | "deviceGuid": device_guid, 192 | "parameters": parameters 193 | } 194 | _ = self._auth.execute_post( 195 | self._get_device_status_control_url(), payload, "set_device", 200) 196 | return True 197 | return False 198 | 199 | def _read_parameters(self, parameters=dict()): 200 | value = dict() 201 | 202 | _convert = { 203 | 'insideTemperature': 'temperatureInside', 204 | 'outTemperature': 'temperatureOutside', 205 | 'temperatureSet': 'temperature', 206 | 'currencyUnit': 'currencyUnit', 207 | 'energyConsumption': 'energyConsumption', 208 | 'estimatedCost': 'estimatedCost', 209 | 'historyDataList': 'historyDataList', 210 | } 211 | for key in _convert: 212 | if key in parameters: 213 | value[_convert[key]] = parameters[key] 214 | 215 | if 'operate' in parameters: 216 | value['power'] = constants.Power(parameters['operate']) 217 | 218 | if 'operationMode' in parameters: 219 | value['mode'] = constants.OperationMode( 220 | parameters['operationMode']) 221 | 222 | if 'fanSpeed' in parameters: 223 | value['fanSpeed'] = constants.FanSpeed(parameters['fanSpeed']) 224 | 225 | if 'airSwingLR' in parameters: 226 | value['airSwingHorizontal'] = constants.AirSwingLR( 227 | parameters['airSwingLR']) 228 | 229 | if 'airSwingUD' in parameters: 230 | value['airSwingVertical'] = constants.AirSwingUD( 231 | parameters['airSwingUD']) 232 | 233 | if 'ecoMode' in parameters: 234 | value['eco'] = constants.EcoMode(parameters['ecoMode']) 235 | 236 | if 'nanoe' in parameters: 237 | value['nanoe'] = constants.NanoeMode(parameters['nanoe']) 238 | 239 | if 'fanAutoMode' in parameters: 240 | if parameters['fanAutoMode'] == constants.AirSwingAutoMode.Both.value: 241 | value['airSwingHorizontal'] = constants.AirSwingLR.Auto 242 | value['airSwingVertical'] = constants.AirSwingUD.Auto 243 | elif parameters['fanAutoMode'] == constants.AirSwingAutoMode.AirSwingLR.value: 244 | value['airSwingHorizontal'] = constants.AirSwingLR.Auto 245 | elif parameters['fanAutoMode'] == constants.AirSwingAutoMode.AirSwingUD.value: 246 | value['airSwingVertical'] = constants.AirSwingUD.Auto 247 | 248 | return value 249 | 250 | def _get_group_url(self): 251 | return '{base_url}/device/group'.format( 252 | base_url=constants.BASE_PATH_ACC 253 | ) 254 | 255 | def _get_device_status_url(self, guid): 256 | return '{base_url}/deviceStatus/{guid}'.format( 257 | base_url=constants.BASE_PATH_ACC, 258 | guid=re.sub(r'(?i)\%2f', 'f', quote_plus(guid)) 259 | ) 260 | 261 | def _get_device_status_now_url(self, guid): 262 | return '{base_url}/deviceStatus/now/{guid}'.format( 263 | base_url=constants.BASE_PATH_ACC, 264 | guid=re.sub(r'(?i)\%2f', 'f', quote_plus(guid)) 265 | ) 266 | 267 | def _get_device_status_control_url(self): 268 | return '{base_url}/deviceStatus/control'.format( 269 | base_url=constants.BASE_PATH_ACC 270 | ) 271 | 272 | def _get_device_history_url(self): 273 | return '{base_url}/deviceHistoryData'.format( 274 | base_url=constants.BASE_PATH_ACC, 275 | ) 276 | -------------------------------------------------------------------------------- /pcomfortcloud/authentication.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import hashlib 4 | import json 5 | import random 6 | import string 7 | import time 8 | import urllib 9 | import requests 10 | import re 11 | 12 | from bs4 import BeautifulSoup 13 | from . import exceptions 14 | from . import constants 15 | 16 | def generate_random_string(length): 17 | return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length)) 18 | 19 | 20 | def generate_random_string_hex(length): 21 | return ''.join(random.choice(string.hexdigits) for _ in range(length)) 22 | 23 | 24 | def check_response(response, function_description, expected_status): 25 | if response.status_code != expected_status: 26 | raise exceptions.ResponseError( 27 | f"({function_description}: Expected status code {expected_status}, received: {response.status_code}: " + 28 | f"{response.text}" 29 | ) 30 | 31 | 32 | def get_querystring_parameter_from_header_entry_url(response, header_entry, querystring_parameter): 33 | header_entry_value = response.headers[header_entry] 34 | parsed_url = urllib.parse.urlparse(header_entry_value) 35 | params = urllib.parse.parse_qs(parsed_url.query) 36 | return params.get(querystring_parameter, [None])[0] 37 | 38 | 39 | class Authentication(): 40 | # token: 41 | # - access_token 42 | # - refresh_token 43 | # - id_token 44 | # - unix_timestamp_token_received 45 | # - expires_in_sec 46 | # - acc_client_id 47 | # - scope 48 | 49 | def __init__(self, username, password, token, raw=False): 50 | self._username = username 51 | self._password = password 52 | self._token = token 53 | self._raw = raw 54 | self._app_version = constants.X_APP_VERSION 55 | self._update_app_version() 56 | 57 | def _check_token_is_valid(self): 58 | if self._token is not None: 59 | now = datetime.datetime.now() 60 | now_unix = time.mktime(now.timetuple()) 61 | 62 | # multiple parts in access_token which are separated by . 63 | part_of_token_b64 = str(self._token["access_token"].split(".")[1]) 64 | # as seen here: https://stackoverflow.com/questions/3302946/how-to-decode-base64-url-in-python 65 | part_of_token = base64.urlsafe_b64decode( 66 | part_of_token_b64 + '=' * (4 - len(part_of_token_b64) % 4)) 67 | token_info_json = json.loads(part_of_token) 68 | 69 | expiry_in_token = token_info_json["exp"] 70 | 71 | if (now_unix > expiry_in_token) or \ 72 | (now_unix > self._token["unix_timestamp_token_received"] + self._token["expires_in_sec"]): 73 | 74 | if self._raw: 75 | print("--- Token is expired") 76 | return False 77 | 78 | if self._raw: 79 | print("--- Token is valid") 80 | return True 81 | else: 82 | if self._raw: 83 | print("--- Token is invalid") 84 | return False 85 | 86 | def _get_api_key(self, timestamp: datetime.datetime, token: string): 87 | try: 88 | date = datetime.datetime( 89 | timestamp.year, timestamp.month, timestamp.day, 90 | timestamp.hour, timestamp.minute, timestamp.second, 91 | 0, datetime.timezone.utc 92 | ) 93 | timestamp_ms = str(int(date.timestamp() * 1000)) 94 | 95 | components = [ 96 | 'Comfort Cloud'.encode('utf-8'), 97 | '521325fb2dd486bf4831b47644317fca'.encode('utf-8'), 98 | timestamp_ms.encode('utf-8'), 99 | 'Bearer '.encode('utf-8'), 100 | token.encode('utf-8') 101 | ] 102 | 103 | input_buffer = b''.join(components) 104 | hash_obj = hashlib.sha256() 105 | hash_obj.update(input_buffer) 106 | hash_str = hash_obj.hexdigest() 107 | 108 | result = hash_str[:9] + 'cfc' + hash_str[9:] 109 | return result 110 | except Exception as ex: 111 | raise exceptions.ResponseError( 112 | f"(CFC: Failed to generate API key: " + 113 | f"{ex}" 114 | ) 115 | 116 | def _get_new_token(self): 117 | requests_session = requests.Session() 118 | 119 | # generate initial state and code_challenge 120 | state = generate_random_string(20) 121 | code_verifier = generate_random_string(43) 122 | 123 | code_challenge = base64.urlsafe_b64encode( 124 | hashlib.sha256( 125 | code_verifier.encode('utf-8') 126 | ).digest()).split('='.encode('utf-8'))[0].decode('utf-8') 127 | 128 | # -------------------------------------------------------------------- 129 | # AUTHORIZE 130 | # -------------------------------------------------------------------- 131 | 132 | response = requests_session.get( 133 | f'{constants.BASE_PATH_AUTH}/authorize', 134 | headers={ 135 | "user-agent": "okhttp/4.10.0", 136 | }, 137 | params={ 138 | "scope": "openid offline_access comfortcloud.control a2w.control", 139 | "audience": f"https://digital.panasonic.com/{constants.APP_CLIENT_ID}/api/v1/", 140 | "protocol": "oauth2", 141 | "response_type": "code", 142 | "code_challenge": code_challenge, 143 | "code_challenge_method": "S256", 144 | "auth0Client": constants.AUTH_0_CLIENT, 145 | "client_id": constants.APP_CLIENT_ID, 146 | "redirect_uri": constants.REDIRECT_URI, 147 | "state": state, 148 | }, 149 | allow_redirects=False) 150 | check_response(response, 'authorize', 302) 151 | 152 | # ------------------------------------------------------------------- 153 | # FOLLOW REDIRECT 154 | # ------------------------------------------------------------------- 155 | 156 | location = response.headers['Location'] 157 | state = get_querystring_parameter_from_header_entry_url( 158 | response, 'Location', 'state') 159 | 160 | if not location.startswith(constants.REDIRECT_URI): 161 | response = requests_session.get( 162 | f"{constants.BASE_PATH_AUTH}/{location}", 163 | allow_redirects=False) 164 | check_response(response, 'authorize_redirect', 200) 165 | 166 | # get the "_csrf" cookie 167 | csrf = response.cookies['_csrf'] 168 | 169 | # ------------------------------------------------------------------- 170 | # LOGIN 171 | # ------------------------------------------------------------------- 172 | 173 | response = requests_session.post( 174 | f'{constants.BASE_PATH_AUTH}/usernamepassword/login', 175 | headers={ 176 | "Auth0-Client": constants.AUTH_0_CLIENT, 177 | "user-agent": "okhttp/4.10.0", 178 | }, 179 | json={ 180 | "client_id": constants.APP_CLIENT_ID, 181 | "redirect_uri": constants.REDIRECT_URI, 182 | "tenant": "pdpauthglb-a1", 183 | "response_type": "code", 184 | "scope": "openid offline_access comfortcloud.control a2w.control", 185 | "audience": f"https://digital.panasonic.com/{constants.APP_CLIENT_ID}/api/v1/", 186 | "_csrf": csrf, 187 | "state": state, 188 | "_intstate": "deprecated", 189 | "username": self._username, 190 | "password": self._password, 191 | "lang": "en", 192 | "connection": "PanasonicID-Authentication" 193 | }, 194 | allow_redirects=False) 195 | check_response(response, 'login', 200) 196 | 197 | # ------------------------------------------------------------------- 198 | # CALLBACK 199 | # ------------------------------------------------------------------- 200 | 201 | # get wa, wresult, wctx from body 202 | soup = BeautifulSoup(response.content, "html.parser") 203 | input_lines = soup.find_all("input", {"type": "hidden"}) 204 | parameters = dict() 205 | for input_line in input_lines: 206 | parameters[input_line.get("name")] = input_line.get("value") 207 | 208 | user_agent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " 209 | user_agent += "(KHTML, like Gecko) Chrome/113.0.0.0 Mobile Safari/537.36" 210 | 211 | response = requests_session.post( 212 | url=f"{constants.BASE_PATH_AUTH}/login/callback", 213 | data=parameters, 214 | headers={ 215 | "Content-Type": "application/x-www-form-urlencoded", 216 | "User-Agent": user_agent, 217 | }, 218 | allow_redirects=False) 219 | check_response(response, 'login_callback', 302) 220 | 221 | # ------------------------------------------------------------------ 222 | # FOLLOW REDIRECT 223 | # ------------------------------------------------------------------ 224 | 225 | location = response.headers['Location'] 226 | 227 | response = requests_session.get( 228 | f"{constants.BASE_PATH_AUTH}/{location}", 229 | allow_redirects=False) 230 | check_response(response, 'login_redirect', 302) 231 | 232 | # ------------------------------------------------------------------ 233 | # GET TOKEN 234 | # ------------------------------------------------------------------ 235 | 236 | code = get_querystring_parameter_from_header_entry_url( 237 | response, 'Location', 'code') 238 | 239 | # do before, so that timestamp is older rather than newer 240 | now = datetime.datetime.now() 241 | unix_time_token_received = time.mktime(now.timetuple()) 242 | 243 | response = requests_session.post( 244 | f'{constants.BASE_PATH_AUTH}/oauth/token', 245 | headers={ 246 | "Auth0-Client": constants.AUTH_0_CLIENT, 247 | "user-agent": "okhttp/4.10.0", 248 | }, 249 | json={ 250 | "scope": "openid", 251 | "client_id": constants.APP_CLIENT_ID, 252 | "grant_type": "authorization_code", 253 | "code": code, 254 | "redirect_uri": constants.REDIRECT_URI, 255 | "code_verifier": code_verifier 256 | }, 257 | allow_redirects=False) 258 | check_response(response, 'get_token', 200) 259 | 260 | token_response = json.loads(response.text) 261 | 262 | # ------------------------------------------------------------------ 263 | # RETRIEVE ACC_CLIENT_ID 264 | # ------------------------------------------------------------------ 265 | now = datetime.datetime.now() 266 | response = requests.post( 267 | f'{constants.BASE_PATH_ACC}/auth/v2/login', 268 | headers={ 269 | "Content-Type": "application/json;charset=utf-8", 270 | "User-Agent": "G-RAC", 271 | "x-app-name": "Comfort Cloud", 272 | "x-app-timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), 273 | "x-app-type": "1", 274 | "x-app-version": self._app_version, 275 | "x-cfc-api-key": self._get_api_key(now, token_response["access_token"]), 276 | "x-user-authorization-v2": "Bearer " + token_response["access_token"] 277 | }, 278 | json={ 279 | "language": 0 280 | }) 281 | check_response(response, 'get_acc_client_id', 200) 282 | 283 | json_body = json.loads(response.text) 284 | acc_client_id = json_body["clientId"] 285 | 286 | self._token = { 287 | "access_token": token_response["access_token"], 288 | "refresh_token": token_response["refresh_token"], 289 | "id_token": token_response["id_token"], 290 | "unix_timestamp_token_received": unix_time_token_received, 291 | "expires_in_sec": token_response["expires_in"], 292 | "acc_client_id": acc_client_id, 293 | "scope": token_response["scope"] 294 | } 295 | 296 | def get_token(self): 297 | return self._token 298 | 299 | def set_token(self, token): 300 | self._token = token 301 | 302 | def is_token_valid(self): 303 | return self._check_token_is_valid() 304 | 305 | def login(self): 306 | if self._token is not None: 307 | if not self._check_token_is_valid(): 308 | self._refresh_token() 309 | return "Refreshing" 310 | else: 311 | self._get_new_token() 312 | return "Authenticating" 313 | 314 | return "Valid" 315 | 316 | def logout(self): 317 | response = requests.post( 318 | f"{constants.BASE_PATH_ACC}/auth/v2/logout", 319 | headers=self._get_header_for_api_calls() 320 | ) 321 | check_response(response, "logout", 200) 322 | if json.loads(response.text)["result"] != 0: 323 | # issue during logout, but do we really care? 324 | pass 325 | 326 | def _refresh_token(self): 327 | # do before, so that timestamp is older rather than newer 328 | now = datetime.datetime.now() 329 | unix_time_token_received = time.mktime(now.timetuple()) 330 | 331 | response = requests.post( 332 | f'{constants.BASE_PATH_AUTH}/oauth/token', 333 | headers={ 334 | "Auth0-Client": constants.AUTH_0_CLIENT, 335 | "user-agent": "okhttp/4.10.0", 336 | }, 337 | json={ 338 | "scope": self._token["scope"], 339 | "client_id": constants.APP_CLIENT_ID, 340 | "refresh_token": self._token["refresh_token"], 341 | "grant_type": "refresh_token" 342 | }, 343 | allow_redirects=False) 344 | 345 | if response.status_code != 200: 346 | self._get_new_token() 347 | return 348 | 349 | token_response = json.loads(response.text) 350 | 351 | self._token = { 352 | "access_token": token_response["access_token"], 353 | "refresh_token": token_response["refresh_token"], 354 | "id_token": token_response["id_token"], 355 | "unix_timestamp_token_received": unix_time_token_received, 356 | "expires_in_sec": token_response["expires_in"], 357 | "acc_client_id": self._token["acc_client_id"], 358 | "scope": token_response["scope"] 359 | } 360 | 361 | def _get_header_for_api_calls(self, no_client_id=False): 362 | now = datetime.datetime.now() 363 | return { 364 | "Content-Type": "application/json;charset=utf-8", 365 | "x-app-name": "Comfort Cloud", 366 | "user-agent": "G-RAC", 367 | "x-app-timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), 368 | "x-app-type": "1", 369 | "x-app-version": self._app_version, 370 | "x-cfc-api-key": self._get_api_key(now, self._token["access_token"]), 371 | "x-client-id": self._token["acc_client_id"], 372 | "x-user-authorization-v2": "Bearer " + self._token["access_token"] 373 | } 374 | 375 | def _get_user_info(self): 376 | response = requests.get( 377 | f'{constants.BASE_PATH_AUTH}/userinfo', 378 | headers={ 379 | "Auth0-Client": self.AUTH_0_CLIENT, 380 | "Authorization": "Bearer " + self._token["access_token"] 381 | }) 382 | check_response(response, 'userinfo', 200) 383 | 384 | def execute_post(self, 385 | url, 386 | json_data, 387 | function_description, 388 | expected_status_code): 389 | self._ensure_valid_token() 390 | 391 | try: 392 | response = requests.post( 393 | url, 394 | json=json_data, 395 | headers=self._get_header_for_api_calls() 396 | ) 397 | except requests.exceptions.RequestException as ex: 398 | raise exceptions.RequestError(ex) 399 | 400 | self._print_response_if_raw_is_set(response, function_description) 401 | check_response(response, function_description, expected_status_code) 402 | return json.loads(response.text) 403 | 404 | def execute_get(self, url, function_description, expected_status_code): 405 | self._ensure_valid_token() 406 | 407 | try: 408 | response = requests.get( 409 | url, 410 | headers=self._get_header_for_api_calls() 411 | ) 412 | except requests.exceptions.RequestException as ex: 413 | raise exceptions.RequestError(ex) 414 | 415 | self._print_response_if_raw_is_set(response, function_description) 416 | check_response(response, function_description, expected_status_code) 417 | return json.loads(response.text) 418 | 419 | def _print_response_if_raw_is_set(self, response, function_description): 420 | if self._raw: 421 | print("=" * 79) 422 | print(f"Response: {function_description}") 423 | print("=" * 79) 424 | print(f"Status: {response.status_code}") 425 | print("-" * 79) 426 | print("Headers:") 427 | for header in response.headers: 428 | print(f'{header}: {response.headers[header]}') 429 | print("-" * 79) 430 | print("Response body:") 431 | print(response.text) 432 | print("-" * 79) 433 | 434 | def _ensure_valid_token(self): 435 | if self._check_token_is_valid(): 436 | return 437 | self._refresh_token() 438 | 439 | def _update_app_version(self): 440 | if self._raw: 441 | print("--- auto detecting latest app version") 442 | try: 443 | response = requests.get("https://play.google.com/store/apps/details?id=com.panasonic.ACCsmart") 444 | responseText = response.content.decode("utf-8") 445 | version_match = re.search(r'\[\"(\d+\.\d+\.\d+)\"\]', responseText) 446 | if version_match: 447 | version = version_match.group(1) 448 | self._app_version = version 449 | if self._raw: 450 | print("--- found version: {}".format(self._app_version)) 451 | return 452 | else: 453 | self._app_version = constants.X_APP_VERSION 454 | if self._raw: 455 | print("--- error finding version") 456 | return 457 | 458 | except Exception: 459 | self._app_version = constants.X_APP_VERSION 460 | if self._raw: 461 | print("--- failed to detect app version using version default") 462 | pass 463 | 464 | --------------------------------------------------------------------------------