From 4099a01afae14f939701269e62eb3c1b47fd6430 Mon Sep 17 00:00:00 2001 From: Tilman Vogel Date: Sun, 26 Nov 2023 21:12:57 +0100 Subject: [PATCH] Update documentation, rename action scripts This brings the README.md up-to-date and renames the Tuya action scripts such that they are less ambiguous. iot-tuya.pl: add check for missing configuration; --- README.md | 49 ++++++++++++++---- example.env | 38 +++++++------- tuya.py => iot-tuya.py | 56 ++++++++++++--------- smartlife.py => tuya-qr-sharing.py | 79 +++++++++++++++--------------- 4 files changed, 131 insertions(+), 91 deletions(-) rename tuya.py => iot-tuya.py (57%) rename smartlife.py => tuya-qr-sharing.py (64%) diff --git a/README.md b/README.md index c3c64e3..684586f 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ -# Controlling smart heating valves from a CalDAV resource +# Controlling smart heating valves from a CalDAV calendar resource This Python script can be used to check a CalDAV resource for occupation of a -room and control heating using generic web requests accordingly. This script -does the check just once for the current instance of time and invokes -correspondingly one of two web requests. In order to run this script regularly, -use `cron` or a similar service. I highly recommend using +room and control heating using web requests or Tuya scenes accordingly. This +script does the check just once for the current instance of time and +consequently invokes one of two configured actions. In order to run this script +regularly, use `cron` or a similar service. I highly recommend using [cronic](https://habilis.net/cronic/) for wrapping the call to this script in cron. Also, logging can be achieved using `tee -a` to a file of your choice (see [`caldav-trigger.sh`](caldav-trigger.sh)). Even though this script was written for smart heating valves, it can of course -be used for anything that you can control in an on/off fashion using web +be used for anything that you can control in an *on*/*off* fashion using web requests. Parameters and credentials for accessing the CalDAV resource and invoking the -web request are configured using the system environment or a `.env` file. See -[`example.env`](example.env). +intended actions are configured using the system environment or a `.env` file. +See [`example.env`](example.env). -Furthermore, there are three parameters controlling the behavior: +There are three parameters controlling the behavior: * `preheat_minutes`: This time ahead of an occupation, the `heat_on` action is invoked. * `cooloff_minutes`: This time ahead of the end of an occupation, the `heat_off` @@ -47,11 +47,40 @@ be shortened, prolonged, deleted or created and the `no_heat_tag` can be added or removed with short notice and the next run of the script will make the changes effective. +## Actions + +In order to turn the heating *on* or *off*, an action script configured in +`.env` is invoked with `on` or `off` as parameter. Currently, these are the +options: + +- [webhooks.py](webhooks.py): This script allows to call generic web-hooks, e.g. + on [IFTTT](https://ifttt.com) +- [iot-tuya.py](iot-tuya.py): This script allows to invoke Tuya scenes via the + Tuya Open API. You'll need to register at https://iot.tuya.com/, create a + cloud project and link your Tuya/SmartLife account there. +- [tuya-qr-sharing.py](tuya-qr-sharing.py): This script will invoke Tuya scenes + via the + [Tuya Device Sharing SDK](https://github.com/tuya/tuya-device-sharing-sdk). + This is easier because you can authorize the script via scanning a QR code in + your Tuya or SmartLife app without registering a project on + https://iot.tuya.com/. + +You can easily adapt this logic to your own action scripts by implementing new +action scripts. Pull-requests are welcome! + +The [iot-tuya.py](iot-tuya.py) and [tuya-qr-sharing.py](tuya-qr-sharing.py) +scripts have additional commands that help in configuring the desired Tuya +scenes and performing the QR authorization step. Please run them without +command line parameters to see the options. + ## Code -The CalDAV and web request access is coded in the main script +The CalDAV and action invocation is coded in the main script [`caldav-trigger.py`](caldav-trigger.py), the evaluation logic is in [`logic.py`](logic.py) with `pytest-mock` unit-tests in [`logic_test.py`](logic_test.py). +The actions are implemented in the respective scripts, linked above. + +All dependencies are given in [requirements.txt](requirements.txt). diff --git a/example.env b/example.env index d99be6c..78c84f9 100644 --- a/example.env +++ b/example.env @@ -10,7 +10,7 @@ no_heat_tag = "!cold!" preheat_minutes = 60 cooloff_minutes = 30 -action = "webhooks.py" # or "tuya.py" or "smartlife.py" +action = "webhooks.py" # or "iot-tuya.py" or "tuya-qr-sharing.py" webhooks_url="https://maker.ifttt.com/trigger/{action}/with/key/{key}" webhooks_key="webhooks_key" @@ -20,22 +20,22 @@ webhooks_heat_off_action="heating_off" # defaults to "no timeout" # webhooks_timeout=60 -tuya_access_id = '' -tuya_access_secret = '' -tuya_username = '' -tuya_password = '' -tuya_endpoint_url = 'https://openapi.tuyaeu.com' -tuya_country_code = '49' -tuya_schema = 'tuyaSmart' -tuya_home = '' -tuya_scene_off = '' -tuya_scene_on = '' +iot_tuya_access_id = '' +iot_tuya_access_secret = '' +iot_tuya_username = '' +iot_tuya_password = '' +iot_tuya_endpoint_url = 'https://openapi.tuyaeu.com' +iot_tuya_country_code = '49' +iot_tuya_schema = 'tuyaSmart' +iot_tuya_home = '' +iot_tuya_scene_off = '' +iot_tuya_scene_on = '' -smartlife_user_code = '' -smartlife_username = '' -smartlife_token_info = '' -smartlife_terminal_id= '' -smartlife_endpoint = '' -smartlife_home = '' -smartlife_scene_off = '' -smartlife_scene_on = '' +tuya_qr_sharing_user_code = '' +tuya_qr_sharing_username = '' +tuya_qr_sharing_token_info = '' +tuya_qr_sharing_terminal_id= '' +tuya_qr_sharing_endpoint = '' +tuya_qr_sharing_home = '' +tuya_qr_sharing_scene_off = '' +tuya_qr_sharing_scene_on = '' diff --git a/tuya.py b/iot-tuya.py similarity index 57% rename from tuya.py rename to iot-tuya.py index fdbe748..4c077fb 100755 --- a/tuya.py +++ b/iot-tuya.py @@ -12,25 +12,37 @@ EXIT_AUTHENTICATION_FAILED = 1 EXIT_SYNTAX_ERROR = 2 EXIT_LIST_HOMES_FAILED = 3 -EXIT_TUYA_HOME_MISSING = 4 +EXIT_HOME_MISSING = 4 EXIT_LIST_SCENES_FAILED = 5 -EXIT_TUYA_SCENE_MISSING = 6 +EXIT_SCENE_MISSING = 6 EXIT_TRIGGER_SCENE_FAILED = 7 def main() -> int: dotenv.load_dotenv() + for key in [ 'endpoint_url', + 'access_id', + 'access_secret', + 'username', + 'password', + 'country_code', + 'schema' ]: + if not os.environ.get('iot_tuya_' + key): + print( f"Please specify missing {'iot_tuya_' + key} in your .env file.", + file=sys.stderr) + return EXIT_AUTHENTICATION_FAILED + # Init # TUYA_LOGGER.setLevel(logging.DEBUG) openapi = TuyaOpenAPI( - os.environ.get('tuya_endpoint_url'), - os.environ.get('tuya_access_id'), - os.environ.get('tuya_access_secret')) + os.environ.get('iot_tuya_endpoint_url'), + os.environ.get('iot_tuya_access_id'), + os.environ.get('iot_tuya_access_secret')) response = openapi.connect( - os.environ.get('tuya_username'), - os.environ.get('tuya_password'), - os.environ.get('tuya_country_code'), - os.environ.get('tuya_schema')) + os.environ.get('iot_tuya_username'), + os.environ.get('iot_tuya_password'), + os.environ.get('iot_tuya_country_code'), + os.environ.get('iot_tuya_schema')) if not response['success']: print('Authentication failed: %s' % response['msg'], file=sys.stderr) @@ -44,7 +56,7 @@ def main() -> int: cmd = '' if not cmd in [ 'homes', 'scenes', 'on', 'off' ]: - print('Syntax: tuya.py (on|off|homes|scenes)', file=sys.stderr) + print('Syntax: iot-tuya.py (on|off|homes|scenes)', file=sys.stderr) return EXIT_SYNTAX_ERROR; if cmd == 'homes': @@ -58,12 +70,12 @@ def main() -> int: print('%s: %s' % (home['name'], home['home_id'])) return EXIT_OK - home_id = os.environ.get('tuya_home') + home_id = os.environ.get('iot_tuya_home') if cmd == 'scenes': if not home_id: - print('Set tuya_home in .env first, in order to list scenes!', file=sys.stderr) - return EXIT_TUYA_HOME_MISSING + print('Set iot_tuya_home in .env first, in order to list scenes!', file=sys.stderr) + return EXIT_HOME_MISSING response = openapi.get('/v1.1/homes/{home_id}/scenes'.format(home_id = home_id)) if not response['success']: print('Error listing scenes: %s' % response['msg'], file=sys.stderr) @@ -74,25 +86,25 @@ def main() -> int: return EXIT_OK if not home_id: - print('Set tuya_home in .env first, in order to trigger scenes!', file=sys.stderr) - return EXIT_TUYA_HOME_MISSING + print('Set iot_tuya_home in .env first, in order to trigger scenes!', file=sys.stderr) + return EXIT_HOME_MISSING if cmd == 'on': - scene_id = os.environ.get('tuya_scene_on') + scene_id = os.environ.get('iot_tuya_scene_on') if not scene_id: - print('Set tuya_scene_on in .env first!', file=sys.stderr) - return EXIT_TUYA_SCENE_MISSING + print('Set iot_tuya_scene_on in .env first!', file=sys.stderr) + return EXIT_SCENE_MISSING elif cmd == 'off': - scene_id = os.environ.get('tuya_scene_off') + scene_id = os.environ.get('iot_tuya_scene_off') if not scene_id: - print('Set tuya_scene_off in .env first!', file=sys.stderr) - return EXIT_TUYA_SCENE_MISSING + print('Set iot_tuya_scene_off in .env first!', file=sys.stderr) + return EXIT_SCENE_MISSING response = openapi.post('/v1.0/homes/{home_id}/scenes/{scene_id}/trigger'.format(home_id = home_id, scene_id = scene_id)) if not response['success']: print('Error triggering scene: %s' % response['msg'], file=sys.stderr) return EXIT_TRIGGER_SCENE_FAILED - print('Tuya trigger succeeded.') + print('iot.tuya trigger succeeded.') return EXIT_OK if __name__ == '__main__': diff --git a/smartlife.py b/tuya-qr-sharing.py similarity index 64% rename from smartlife.py rename to tuya-qr-sharing.py index 22e923e..56b5221 100755 --- a/smartlife.py +++ b/tuya-qr-sharing.py @@ -13,13 +13,12 @@ import pyqrcode from tuya_sharing import LoginControl, Manager, SharingTokenListener, logger -EXIT_OK = 0 -EXIT_SYNTAX_ERROR = 1 -EXIT_SMARTLIFE_USER_CODE_MISSING = 2 -EXIT_AUTHENTICATION_FAILED = 3 -EXIT_SMARTLIFE_HOME_MISSING = 4 -EXIT_SMARTLIFE_SCENE_MISSING = 5 -EXIT_TRIGGER_SCENE_FAILED = 6 +EXIT_OK = 0 +EXIT_SYNTAX_ERROR = 1 +EXIT_AUTHENTICATION_FAILED = 2 +EXIT_HOME_MISSING = 3 +EXIT_SCENE_MISSING = 4 +EXIT_TRIGGER_SCENE_FAILED = 5 URL_PATH = "apigw.iotbing.com" CONF_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" @@ -38,7 +37,7 @@ def update_token(self, new_token_info: [str, Any]): LOGGER.debug("update token info : %s", new_token_info) global token_info token_info = new_token_info - dotenv.set_key(self.dotenv_file, 'smartlife_token_info', json.dumps(token_info)) + dotenv.set_key(self.dotenv_file, 'tuya_qr_sharing_token_info', json.dumps(token_info)) def main() -> int: @@ -59,27 +58,27 @@ def main() -> int: cmds = [ 'login', 'logout', 'homes', 'scenes', 'on', 'off' ] if not cmd in cmds: - print('Syntax: smartlife.py (%s)' % '|'.join(cmds), file=sys.stderr) + print('Syntax: tuya-qr-sharing.py (%s)' % '|'.join(cmds), file=sys.stderr) return EXIT_SYNTAX_ERROR; session = requests.session() - user_code = os.environ.get('smartlife_user_code') - username = os.environ.get('smartlife_username') - terminal_id = os.environ.get('smartlife_terminal_id') - endpoint = os.environ.get('smartlife_endpoint') + user_code = os.environ.get('tuya_qr_sharing_user_code') + username = os.environ.get('tuya_qr_sharing_username') + terminal_id = os.environ.get('tuya_qr_sharing_terminal_id') + endpoint = os.environ.get('tuya_qr_sharing_endpoint') global token_info try: - token_info = json.loads(os.environ.get('smartlife_token_info')) + token_info = json.loads(os.environ.get('tuya_qr_sharing_token_info')) except TypeError: token_info = None if cmd == 'logout': - dotenv.unset_key(dotenv_file, 'smartlife_token_info') - dotenv.unset_key(dotenv_file, 'smartlife_username') - dotenv.unset_key(dotenv_file, 'smartlife_terminal_id') - dotenv.unset_key(dotenv_file, 'smartlife_endpoint') + dotenv.unset_key(dotenv_file, 'tuya_qr_sharing_token_info') + dotenv.unset_key(dotenv_file, 'tuya_qr_sharing_username') + dotenv.unset_key(dotenv_file, 'tuya_qr_sharing_terminal_id') + dotenv.unset_key(dotenv_file, 'tuya_qr_sharing_endpoint') return EXIT_OK if cmd == 'login': @@ -89,7 +88,7 @@ def main() -> int: if not user_code: user_code = input('SmartLife user-code from Settings/Account: ') - dotenv.set_key(dotenv_file, 'smartlife_user_code', user_code) + dotenv.set_key(dotenv_file, 'tuya_qr_sharing_user_code', user_code) login_control = LoginControl() @@ -123,10 +122,10 @@ def main() -> int: username = info.get('username') terminal_id = info.get('terminal_id') endpoint = info.get('endpoint') - dotenv.set_key(dotenv_file, 'smartlife_username', username) - dotenv.set_key(dotenv_file, 'smartlife_token_info', json.dumps(token_info)) - dotenv.set_key(dotenv_file, 'smartlife_terminal_id', terminal_id) - dotenv.set_key(dotenv_file, 'smartlife_endpoint', endpoint) + dotenv.set_key(dotenv_file, 'tuya_qr_sharing_username', username) + dotenv.set_key(dotenv_file, 'tuya_qr_sharing_token_info', json.dumps(token_info)) + dotenv.set_key(dotenv_file, 'tuya_qr_sharing_terminal_id', terminal_id) + dotenv.set_key(dotenv_file, 'tuya_qr_sharing_endpoint', endpoint) break print('You are logged in.') @@ -137,7 +136,7 @@ def main() -> int: return EXIT_AUTHENTICATION_FAILED token_listener = TokenListener(dotenv_file) - smartlife_manager = Manager( + tuya_sharing_manager = Manager( CONF_CLIENT_ID, user_code, terminal_id, @@ -147,46 +146,46 @@ def main() -> int: ) try: - smartlife_manager.update_device_cache() + tuya_sharing_manager.update_device_cache() except Exception as e: print('Cannot access Smartlife (%s)' % (e.args), file=sys.stderr) return EXIT_AUTHENTICATION_FAILED - smartlife_manager.user_homes.sort(key = operator.attrgetter('name')) + tuya_sharing_manager.user_homes.sort(key = operator.attrgetter('name')) if cmd == 'homes': print('Homes:') - for home in smartlife_manager.user_homes: + for home in tuya_sharing_manager.user_homes: print('%10s: %s' % (home.id, home.name)) return EXIT_OK - home_id = os.environ.get('smartlife_home') + home_id = os.environ.get('tuya_qr_sharing_home') if cmd == 'scenes': - for home in smartlife_manager.user_homes: + for home in tuya_sharing_manager.user_homes: print('Scenes in home %s (%s):' % (home.name, home.id)); - for scene in sorted(smartlife_manager.scene_repository.query_scenes([home.id]), + for scene in sorted(tuya_sharing_manager.scene_repository.query_scenes([home.id]), key = operator.attrgetter('name')): print(' %s: %s' % (scene.scene_id, scene.name)); return EXIT_OK if not home_id: - print('Set smartlife_home in .env first, in order to trigger scenes!', file=sys.stderr) - return EXIT_SMARTLIFE_HOME_MISSING + print('Set tuya_qr_sharing_home in .env first, in order to trigger scenes!', file=sys.stderr) + return EXIT_HOME_MISSING if cmd == 'on': - scene_id = os.environ.get('smartlife_scene_on') + scene_id = os.environ.get('tuya_qr_sharing_scene_on') if not scene_id: - print('Set smartlife_scene_on in .env first!', file=sys.stderr) - return EXIT_SMARTLIFE_SCENE_MISSING + print('Set tuya_qr_sharing_scene_on in .env first!', file=sys.stderr) + return EXIT_SCENE_MISSING elif cmd == 'off': - scene_id = os.environ.get('smartlife_scene_off') + scene_id = os.environ.get('tuya_qr_sharing_scene_off') if not scene_id: - print('Set smartlife_scene_off in .env first!', file=sys.stderr) - return EXIT_SMARTLIFE_SCENE_MISSING + print('Set tuya_qr_sharing_scene_off in .env first!', file=sys.stderr) + return EXIT_SCENE_MISSING try: - response = smartlife_manager.scene_repository.trigger_scene(home_id, scene_id) + response = tuya_sharing_manager.scene_repository.trigger_scene(home_id, scene_id) except Exception as e: print('Error triggering scene (%s)' % (e.args), file=sys.stderr) return EXIT_TRIGGER_SCENE_FAILED @@ -194,7 +193,7 @@ def main() -> int: if not response: print('Triggering scene reported failure', file=sys.stderr) return EXIT_TRIGGER_SCENE_FAILED - print('Smartlife trigger succeeded.') + print('tuya_sharing trigger succeeded.') return EXIT_OK if __name__ == '__main__':