diff --git a/README.md b/README.md index 14ce289..d8f6bd8 100644 --- a/README.md +++ b/README.md @@ -637,6 +637,8 @@ By default, the scan functions will retry 15 times to find new devices. If you a ## Troubleshooting * Tuya devices only allow one TCP connection at a time. Make sure you close the TuyaSmart or SmartLife app before using *TinyTuya* to connect. +* **Battery-powered devices** (sensors, door/window contacts, etc.) are asleep most of the time and do not maintain a local network connection. They will not appear in scans and cannot be controlled locally — they only push data to the cloud when triggered. This is expected behaviour, not a TinyTuya bug. +* **Polling too aggressively can cause devices to drop or reset their connection.** Avoid polling faster than once per second for most devices. For energy-monitoring plugs and other data-heavy devices, a 5–10 second interval is safer. Use `set_socketPersistent(True)` with a heartbeat loop rather than opening a new connection on every poll. * Some devices ship with older firmware that may not work with *TinyTuya*. If you're experiencing issues, please try updating the device's firmware in the official app. * The LOCAL KEY for Tuya devices will change every time a device is removed and re-added to the TuyaSmart app. If you're getting decrypt errors, try getting the key again as it might have changed. * Devices running protocol version 3.1 (e.g. below Firmware 1.0.5) do not require a device *Local_Key* to read the status. All devices will require a device *Local_Key* to control the device. diff --git a/RELEASE.md b/RELEASE.md index d360cdc..3cbe736 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,15 @@ # RELEASE NOTES +## v1.17.7 - UX Improvements + +* Scanner: Improved messaging for devices with no IP address — now clearly indicates the device may be battery-powered or sleeping and that local control is not supported, instead of the generic "Error: No IP found". +* Wizard: When the Tuya Cloud API returns a "permission deny" error (or error code 1010), the wizard now prints a targeted hint suggesting the user check their IoT Core service subscription at https://iot.tuya.com. +* README: Added troubleshooting notes clarifying battery-powered device limitations and warning against aggressive polling intervals that can cause devices to drop or reset their connection. +* CLI: New `monitor` command added: Connects to device and waits for async status updates. +* CLI (`on`, `off`, `set`, `get`, `monitor`): Improved handling of device local keys that contain special shell characters (`$`, `#`, `=`, `:`, `!`) - re: [#688](https://github.com/jasonacox/tinytuya/issues/688): + * If `--key` is omitted and the key is not found in `devices.json`, the CLI now **prompts interactively** for the key. Input at a terminal prompt bypasses shell interpretation entirely, so no quoting or escaping is needed. + * Added **key length validation** — Tuya local keys are always exactly 16 characters. If the resolved key is the wrong length (the most common symptom of a shell-escaping problem), a clear error is printed with platform-specific quoting tips for Linux/Mac and Windows CMD. + ## v1.17.6 - RFRemoteControlDevice Bug Fixes * Contrib: Fix `RFRemoteControlDevice` - three bugs that each independently caused `rfstudy_send` commands to be silently ignored by the device by @kongo09 in https://github.com/jasonacox/tinytuya/pull/684: diff --git a/tinytuya/__main__.py b/tinytuya/__main__.py index d9974b3..53af17e 100644 --- a/tinytuya/__main__.py +++ b/tinytuya/__main__.py @@ -16,7 +16,6 @@ """ # Modules -import json import sys import argparse try: @@ -26,7 +25,7 @@ HAVE_ARGCOMPLETE = False from . import wizard, scanner, version, SCANTIME, DEVICEFILE, SNAPSHOTFILE, CONFIGFILE, RAWFILE, set_debug -from .core import Device +from .cli import _run_list_command, _run_device_command, _monitor_device prog = 'python3 -m tinytuya' if sys.argv[0][-11:] == '__main__.py' else None description = 'TinyTuya [%s]' % (version,) @@ -44,34 +43,54 @@ 'scan': 'Scan local network for Tuya devices', 'devices': 'Scan all devices listed in device-file', 'snapshot': 'Scan devices listed in snapshot-file', - 'json': 'Scan devices listed in snapshot-file and display the result as JSON' + 'json': 'Scan devices listed in snapshot-file and display the result as JSON', + 'list': 'List devices from device-file', } + +# Device control commands: on, off, set, get +control_cmds = { + 'on': 'Turn on a device switch', + 'off': 'Turn off a device switch', + 'set': 'Set a DPS value on a device', + 'get': 'Read a DPS value from a device', + 'monitor': 'Read status and monitor device for updates', +} + +# Add device control commands to command list +cmd_list.update(control_cmds) + for sp in cmd_list: subparsers[sp] = subparser.add_parser(sp, help=cmd_list[sp]) subparsers[sp].add_argument( '-debug', '-d', help='Enable debug messages', action='store_true', dest='debug2' ) - if sp != 'json': + if sp not in ('json', 'list'): if sp != 'snapshot': - subparsers[sp].add_argument( 'max_time', help='Maximum time to find Tuya devices [Default: %s]' % SCANTIME, nargs='?', type=int ) + if sp in control_cmds: + subparsers[sp].add_argument( '-maxtime', help='Maximum time to find Tuya devices [Default: %s]' % SCANTIME, type=int, dest='max_time' ) + else: + subparsers[sp].add_argument( 'max_time', help='Maximum time to find Tuya devices [Default: %s]' % SCANTIME, nargs='?', type=int ) subparsers[sp].add_argument( '-force', '-f', metavar='0.0.0.0/24', help='Force network scan of device IP addresses. Auto-detects net/mask if none provided', action='append', nargs='*' ) subparsers[sp].add_argument( '-no-broadcasts', help='Ignore broadcast packets when force scanning', action='store_true' ) subparsers[sp].add_argument( '-nocolor', help='Disable color text output', action='store_true' ) subparsers[sp].add_argument( '-yes', '-y', help='Answer "yes" to all questions', action='store_true' ) - if sp != 'scan': + if sp != 'scan' and sp not in control_cmds: subparsers[sp].add_argument( '-no-poll', '-no', help='Answer "no" to "Poll?" (overrides -yes)', action='store_true' ) if sp == 'wizard': help = 'JSON file to load/save devices from/to [Default: %s]' % DEVICEFILE - subparsers[sp].add_argument( '-device-file', help=help, default=DEVICEFILE, metavar='FILE' ) + subparsers[sp].add_argument( '-device-file', '--device-file', help=help, default=DEVICEFILE, metavar='FILE' ) subparsers[sp].add_argument( '-raw-response-file', help='JSON file to save the raw server response to [Default: %s]' % RAWFILE, default=RAWFILE, metavar='FILE' ) else: help = 'JSON file to load devices from [Default: %s]' % DEVICEFILE - subparsers[sp].add_argument( '-device-file', help=help, default=DEVICEFILE, metavar='FILE' ) + subparsers[sp].add_argument( '-device-file', '--device-file', help=help, default=DEVICEFILE, metavar='FILE' ) if sp == 'json': # Throw error if file does not exist subparsers[sp].add_argument( '-snapshot-file', help='JSON file to load snapshot from [Default: %s]' % SNAPSHOTFILE, default=SNAPSHOTFILE, metavar='FILE', type=argparse.FileType('r') ) + elif sp in control_cmds: + # Control commands do not use snapshot file + pass else: # May not exist yet, will be created subparsers[sp].add_argument( '-snapshot-file', help='JSON file to load/save snapshot from/to [Default: %s]' % SNAPSHOTFILE, default=SNAPSHOTFILE, metavar='FILE' ) @@ -87,39 +106,24 @@ subparsers['wizard'].add_argument( '-dry-run', help='Do not actually connect to the Cloud', action='store_true' ) # list command -subparsers['list'] = subparser.add_parser('list', help='List devices from device-file') -subparsers['list'].add_argument('-debug', '-d', help='Enable debug messages', action='store_true', dest='debug2') -subparsers['list'].add_argument('-device-file', help='JSON file to load devices from [Default: %s]' % DEVICEFILE, default=DEVICEFILE, metavar='FILE') subparsers['list'].add_argument('--json', help='Display as JSON instead of a table', action='store_true') -# Device control commands: on, off, set, get -control_cmds = { - 'on': 'Turn on a device switch', - 'off': 'Turn off a device switch', - 'set': 'Set a DPS value on a device', - 'get': 'Read a DPS value from a device', -} - +# control commands for sp in control_cmds: - subparsers[sp] = subparser.add_parser(sp, help=control_cmds[sp]) - subparsers[sp].add_argument('-debug', '-d', help='Enable debug messages', action='store_true', dest='debug2') - subparsers[sp].add_argument('-device-file', help='JSON file to load devices from [Default: %s]' % DEVICEFILE, default=DEVICEFILE, metavar='FILE') - - dev_group = subparsers[sp].add_argument_group('Device', '--id (or --name) and --key are required if the --device-file lookup fails') - dev_group.add_argument('--id', help='Device ID', metavar='ID') - dev_group.add_argument('--name', help='Device name (looked up in device-file)', metavar='NAME') - dev_group.add_argument('--key', help='Device local encryption key', metavar='KEY') - dev_group.add_argument('--ip', help='Device IP address (auto-discovered if omitted)', metavar='IP') - dev_group.add_argument('--version', help='Tuya protocol version [Default: 3.3]', default=None, type=float, metavar='VER', dest='dev_version') + dev_group = subparsers[sp].add_argument_group('Device', '--id or --name are required. --id and --key are required if the -device-file lookup fails') + name_id_group = dev_group.add_mutually_exclusive_group(required=True) + name_id_group.add_argument('--id', help='Device ID', metavar='ID') + name_id_group.add_argument('--name', help='Device name (looked up in device-file)', metavar='NAME') + dev_group.add_argument('--key', help='Device local encryption key (prompted if omitted and not in device-file)', metavar='KEY') + dev_group.add_argument('--ip', help='Device IP address (loaded from device-file if omitted or auto-discovered if set to "Auto")', metavar='IP') + dev_group.add_argument('--version', help='Tuya protocol version (auto-discovered if omitted, defaults to 3.3 if not found)', default=None, type=float, metavar='VER', dest='dev_version') if sp in ('on', 'off'): subparsers[sp].add_argument('--dps', help='Switch number [Default: 1]', default=1, type=int, metavar='N') elif sp == 'get': subparsers[sp].add_argument('--dps', help='DPS index to read (omit to return full status)', default=None, type=int, metavar='N') - else: + elif sp == 'set': subparsers[sp].add_argument('--dps', help='DPS index', required=True, type=int, metavar='N') - - if sp == 'set': subparsers[sp].add_argument('--value', help='Value to set. Parsed as JSON if possible (e.g. true, 123, "text"), otherwise sent as a plain string.', required=True, metavar='VALUE') if HAVE_ARGCOMPLETE: @@ -135,188 +139,6 @@ print('Parsed args:', args) set_debug(True) - -def _run_list_command(args): - """Handle the list command.""" - device_file = getattr(args, 'device_file', DEVICEFILE) - try: - with open(device_file, 'r') as f: - tuyadevices = json.load(f) - except FileNotFoundError: - print('Error: device file "%s" not found.' % device_file) - sys.exit(1) - except Exception as e: - print('Error reading device file: %s' % e) - sys.exit(1) - - FIELDS = ('name', 'id', 'key', 'ip', 'version') - - # Normalise rows — prefer last_ip over ip - rows = [] - for dev in tuyadevices: - if not isinstance(dev, dict): - continue - rows.append({ - 'name': dev.get('name', ''), - 'id': dev.get('id', ''), - 'key': dev.get('key', ''), - 'ip': dev.get('last_ip') or dev.get('ip', ''), - 'version': str(dev.get('version', '')), - }) - - if args.json: - print(json.dumps(rows, indent=2)) - return - - # Table output - col_w = {f: len(f) for f in FIELDS} - for row in rows: - for f in FIELDS: - col_w[f] = max(col_w[f], len(str(row[f]))) - - sep = '+' + '+'.join('-' * (col_w[f] + 2) for f in FIELDS) + '+' - header = '|' + '|'.join(' %-*s ' % (col_w[f], f.upper()) for f in FIELDS) + '|' - print(sep) - print(header) - print(sep) - for row in rows: - line = '|' + '|'.join(' %-*s ' % (col_w[f], row[f]) for f in FIELDS) + '|' - print(line) - print(sep) - - -def _run_device_command(args): - """Handle on / off / set / get device control commands.""" - dev_id = args.id - dev_key = args.key - dev_ip = args.ip - dev_version = args.dev_version - device_file = getattr(args, 'device_file', DEVICEFILE) - dev_name = getattr(args, 'name', None) - - # Load devices.json once (best-effort; missing file is fine) - tuyadevices = [] - try: - with open(device_file, 'r') as f: - tuyadevices = json.load(f) - except Exception: - pass - - # Resolve --name to an ID - if dev_name and not dev_id: - match = next( - (dev for dev in tuyadevices - if isinstance(dev, dict) and dev.get('name', '').lower() == dev_name.lower()), - None - ) - if not match: - print('Error: no device named "%s" found in %s.' % (dev_name, device_file)) - sys.exit(1) - dev_id = match.get('id') - - # Look up remaining fields by ID - devinfo = None - if dev_id: - devinfo = next( - (dev for dev in tuyadevices - if isinstance(dev, dict) and dev.get('id') == dev_id), - None - ) - - if devinfo: - if not dev_key: - dev_key = devinfo.get('key') or '' - if not dev_ip: - # devices.json may carry last_ip from a previous scan - dev_ip = devinfo.get('last_ip') or devinfo.get('ip') or None - if dev_version is None: - raw_ver = devinfo.get('version') - if raw_ver: - try: - dev_version = float(raw_ver) - except (TypeError, ValueError): - print( - 'Warning: invalid "version" value (%r) for device %s in %s; ' - 'using default protocol version.' % ( - raw_ver, - devinfo.get('id') or devinfo.get('name') or '', - device_file, - ) - ) - dev_version = None - else: - dev_version = None - - # Validate - if not dev_id: - print('Error: --id or --name is required.') - sys.exit(1) - if not dev_key: - print( - 'Error: device local key not found. Provide --key or ensure ' - 'the device entry in %s has a "key" field.' % device_file - ) - sys.exit(1) - if dev_version is None: - dev_version = 3.3 - if not dev_ip: - dev_ip = 'Auto' - - # Create device handle - try: - d = Device(dev_id, address=dev_ip, local_key=dev_key, version=dev_version) - except RuntimeError as e: - print('Error: %s' % e) - sys.exit(1) - except Exception as e: - print('Error creating device: %s' % e) - sys.exit(1) - - # Execute command - if args.command == 'on': - result = d.turn_on(switch=args.dps) - elif args.command == 'off': - result = d.turn_off(switch=args.dps) - elif args.command == 'set': - # Attempt to parse the value as JSON so that "true", "123", etc. - # are sent with the correct type; fall back to a plain string. - try: - typed_value = json.loads(args.value) - except (ValueError, TypeError): - typed_value = args.value - result = d.set_value(args.dps, typed_value) - elif args.command == 'get': - result = d.status() - if result and 'Err' not in result: - if args.dps is None: - # No --dps given: print full status - print(json.dumps(result)) - return - dps_str = str(args.dps) - if 'dps' in result and dps_str in result['dps']: - # --dps given: print the plain value only - print(json.dumps(result['dps'][dps_str])) - return - else: - available = list(result.get('dps', {}).keys()) - print('Error: DPS %d not found in device response.' % args.dps) - print('Available DPS keys:', available) - sys.exit(1) - # fall through to error check below - else: - result = None - - # Shared error check for on/off/set (and get error path) - if result and 'Err' in result: - print('Error %s: %s' % (result['Err'], result['Error'])) - sys.exit(1) - - if result: - print(json.dumps(result)) - else: - print('OK') - - if args.command: if args.debug2 and not args.debug: print('Parsed args:', args) @@ -364,6 +186,8 @@ def _run_device_command(args): wizard.wizard( color=(not args.nocolor), retries=args.max_time, forcescan=args.force, nocloud=args.dry_run, assume_yes=args.yes, discover=(not args.no_broadcasts), skip_poll=args.no_poll, credentials=creds ) elif args.command == 'list': _run_list_command(args) +elif args.command == 'monitor': + _monitor_device(args) elif args.command in control_cmds: _run_device_command(args) else: diff --git a/tinytuya/cli.py b/tinytuya/cli.py new file mode 100644 index 0000000..bbbc2be --- /dev/null +++ b/tinytuya/cli.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# TinyTuya Module +""" + Python module to interface with Tuya WiFi smart devices + + Author: Jason A. Cox + For more information see https://github.com/jasonacox/tinytuya + + Run TinyTuya Setup Wizard: + python -m tinytuya wizard + This network scan will run if calling this module via command line: + python -m tinytuya + +""" + +# Modules +import getpass +import json +import sys +import time + +from . import scanner, DEVICEFILE +from .core import Device + + +def _run_list_command(args): + """Handle the list command.""" + device_file = getattr(args, 'device_file', DEVICEFILE) + try: + with open(device_file, 'r') as f: + tuyadevices = json.load(f) + except FileNotFoundError: + print('Error: device file "%s" not found.' % device_file) + sys.exit(1) + except Exception as e: + print('Error reading device file: %s' % e) + sys.exit(1) + + FIELDS = ('name', 'id', 'key', 'ip', 'version') + + # Normalise rows — prefer last_ip over ip + rows = [] + for dev in tuyadevices: + if not isinstance(dev, dict): + continue + rows.append({ + 'name': dev.get('name', ''), + 'id': dev.get('id', ''), + 'key': dev.get('key', ''), + 'ip': dev.get('last_ip') or dev.get('ip', ''), + 'version': str(dev.get('version', '')), + }) + + if args.json: + print(json.dumps(rows, indent=2)) + return + + # Table output + col_w = {f: len(f) for f in FIELDS} + for row in rows: + for f in FIELDS: + col_w[f] = max(col_w[f], len(str(row[f]))) + + sep = '+' + '+'.join('-' * (col_w[f] + 2) for f in FIELDS) + '+' + header = '|' + '|'.join(' %-*s ' % (col_w[f], f.upper()) for f in FIELDS) + '|' + print(sep) + print(header) + print(sep) + for row in rows: + line = '|' + '|'.join(' %-*s ' % (col_w[f], row[f]) for f in FIELDS) + '|' + print(line) + print(sep) + + +def _build_device(args): + """Build a Device() object from args, using device file if needed.""" + dev_id = args.id + dev_key = args.key + dev_ip = args.ip + dev_version = args.dev_version + device_file = getattr(args, 'device_file', DEVICEFILE) + dev_name = getattr(args, 'name', None) + + # Load devices.json once (best-effort; missing file is fine) + tuyadevices = [] + try: + with open(device_file, 'r') as f: + tuyadevices = json.load(f) + except Exception: + pass + + # Resolve --name to an ID + if dev_name and not dev_id: + match = next( + (dev for dev in tuyadevices + if isinstance(dev, dict) and dev.get('name', '').lower() == dev_name.lower()), + None + ) + if not match: + print('Error: no device named "%s" found in %s.' % (dev_name, device_file)) + sys.exit(1) + dev_id = match.get('id') + + # Look up remaining fields by ID + devinfo = None + if dev_id: + devinfo = next( + (dev for dev in tuyadevices + if isinstance(dev, dict) and dev.get('id') == dev_id), + None + ) + + if devinfo: + if not dev_key: + dev_key = devinfo.get('key') or '' + if not dev_ip: + # devices.json may carry last_ip from a previous scan + dev_ip = devinfo.get('last_ip') or devinfo.get('ip') or None + if dev_version is None: + raw_ver = devinfo.get('version') + if raw_ver: + try: + dev_version = float(raw_ver) + except (TypeError, ValueError): + print( + 'Warning: invalid "version" value (%r) for device %s in %s; ' + 'using default protocol version.' % ( + raw_ver, + devinfo.get('id') or devinfo.get('name') or '', + device_file, + ) + ) + dev_version = None + else: + dev_version = None + + # Validate + if not dev_id: + print('Error: --id or --name is required.') + sys.exit(1) + # Strip any accidental whitespace (e.g. trailing newline from copy-paste) + # from every key source before validation. + if dev_key: + dev_key = dev_key.strip() + + if not dev_key: + # Interactive prompt as last resort — avoids shell-escaping issues + # entirely for keys that contain $, #, =, :, etc. + # Only prompt when attached to a real terminal; in piped/CI contexts + # there is no user to answer, so exit with a clear error instead. + if not sys.stdin.isatty(): + print( + 'Error: device local key not found. Provide --key or add the device ' + 'to %s.' % device_file + ) + sys.exit(1) + try: + # Use getpass so the key is not echoed to the terminal or logs. + dev_key = getpass.getpass('Enter device local key (16 chars, input hidden): ').strip() + except (KeyboardInterrupt, EOFError): + print() + sys.exit(1) + if not dev_key: + print( + 'Error: device local key not found. Provide --key, add the device to %s, ' + 'or enter it when prompted.' % device_file + ) + sys.exit(1) + + # Validate key length — Tuya local keys are always exactly 16 characters. + # A wrong length is the most common cause of error 914 and is usually a + # shell-escaping problem (e.g. $, #, = being interpreted by the shell). + if len(dev_key) != 16: + print( + 'Error: device key must be exactly 16 characters (got %d).' % len(dev_key) + ) + print(' This is often a shell-escaping issue when the key contains') + print(" special characters such as $, #, =, :, ', or !.") + print(' Tips:') + print(" Linux/Mac - wrap the key in single quotes: --key '$y123c5...'") + print(' Windows CMD - wrap in double quotes and escape ^ before each') + print(' special char, e.g. --key "$y123^=c5..."') + print(' Any platform - omit --key entirely and enter it at the prompt') + print(' (safest option for tricky keys).') + sys.exit(1) + + if (not dev_ip) or (dev_ip.lower().strip() == 'auto') or (not dev_version): + # Call the scanner here so we can pass args to it + all_results = scanner.devices( + verbose=bool(args.debug or args.debug2), scantime=args.max_time, color=(not args.nocolor), poll=False, + forcescan=args.force, byID=True, discover=(not args.no_broadcasts), wantids=(dev_id,), assume_yes=args.yes) + if all_results and dev_id in all_results: + dev_ip = all_results[dev_id]['ip'] + dev_version = all_results[dev_id]['version'] + + if not dev_version: + # Uh oh, scan did not find it! + dev_version = 3.3 + + # Create device handle + try: + d = Device(dev_id, address=dev_ip, local_key=dev_key, version=dev_version) + except RuntimeError as e: + print('Error: %s' % e) + sys.exit(1) + except Exception as e: + print('Error creating device: %s' % e) + sys.exit(1) + + return d + +def _run_device_command(args): + """Handle on / off / set / get device control commands.""" + d = _build_device(args) + + # Execute command + if args.command == 'on': + result = d.turn_on(switch=args.dps) + elif args.command == 'off': + result = d.turn_off(switch=args.dps) + elif args.command == 'set': + # Attempt to parse the value as JSON so that "true", "123", etc. + # are sent with the correct type; fall back to a plain string. + try: + typed_value = json.loads(args.value) + except (ValueError, TypeError): + typed_value = args.value + result = d.set_value(args.dps, typed_value) + elif args.command == 'get': + result = d.status() + if result and 'Err' not in result: + if args.dps is None: + # No --dps given: print full status + print(json.dumps(result)) + return + dps_str = str(args.dps) + if 'dps' in result and dps_str in result['dps']: + # --dps given: print the plain value only + print(json.dumps(result['dps'][dps_str])) + return + else: + available = list(result.get('dps', {}).keys()) + print('Error: DPS %d not found in device response.' % args.dps) + print('Available DPS keys:', available) + sys.exit(1) + # fall through to error check below + else: + result = None + + # Shared error check for on/off/set (and get error path) + if result and 'Err' in result: + print('Error %s: %s' % (result['Err'], result['Error'])) + sys.exit(1) + + if result: + print(json.dumps(result)) + else: + print('OK') + + +def _monitor_device(args): + """Connect to device, get status, and monitor for async updates.""" + d = _build_device(args) + d.set_socketPersistent(True) + + debug = bool(args.debug or args.debug2) + STATUS_TIMER = 30 + KEEPALIVE_TIMER = 12 + + print(" > Send Request for Status < ") + print('Initial Status: %r' % d.status()) + + print(" > Beginning Monitor Loop, -c To Exit <") + heartbeat_time = time.time() + KEEPALIVE_TIMER + status_time = None + + while(True): + if status_time and time.time() >= status_time: + # Uncomment if your device provides power monitoring data but it is not updating + # Some devices require a UPDATEDPS command to force measurements of power. + # print(" > Send DPS Update Request < ") + # Most devices send power data on DPS indexes 18, 19 and 20 + # d.updatedps(['18','19','20'], nowait=True) + # Some Tuya devices will not accept the DPS index values for UPDATEDPS - try: + # payload = d.generate_payload(tinytuya.UPDATEDPS) + # d.send(payload) + + # poll for status + if debug: + print(" > Send Request for Status < ") + data = d.status() + status_time = time.time() + STATUS_TIMER + heartbeat_time = time.time() + KEEPALIVE_TIMER + elif time.time() >= heartbeat_time: + # send a keep-alive + data = d.heartbeat(nowait=False) + heartbeat_time = time.time() + KEEPALIVE_TIMER + else: + # no need to send anything, just listen for an asynchronous update + try: + data = d.receive() + except KeyboardInterrupt: + print(" > Keyboard Interrupt, Exiting! < ") + break + + if data or debug: + print('Received Payload: %r' % data) + + if data and 'Err' in data: + print("Received error! Sleeping for 5 seconds...") + # rate limit retries so we don't hammer the device + time.sleep(5) diff --git a/tinytuya/core/core.py b/tinytuya/core/core.py index d197b4e..506d24b 100644 --- a/tinytuya/core/core.py +++ b/tinytuya/core/core.py @@ -101,7 +101,7 @@ if HAVE_COLORAMA: init() -version_tuple = (1, 17, 6) # Major, Minor, Patch +version_tuple = (1, 17, 7) # Major, Minor, Patch version = __version__ = "%d.%d.%d" % version_tuple __author__ = "jasonacox" diff --git a/tinytuya/scanner.py b/tinytuya/scanner.py index 838ed68..ee2a908 100644 --- a/tinytuya/scanner.py +++ b/tinytuya/scanner.py @@ -1269,6 +1269,7 @@ def tuyaLookup(deviceid): connect_next_round = [] ip_wantips = bool(wantips) ip_wantids = bool(wantids) + can_end_early = ip_wantips or ip_wantids ip_force_wants_end = False ip_scan = False ip_scan_running = False @@ -1797,7 +1798,7 @@ def tuyaLookup(deviceid): if scanned_devices[ip].found and dkey not in devices: devices[dkey] = dev - if verbose: + if verbose and not can_end_early: # Save polling data into snapshot format devicesarray = list(devices.values()) # Add devices from devices.json even if they didn't poll @@ -1841,8 +1842,8 @@ def _display_status( item, dps, term ): name = item['gwId'] ip = item['ip'] if not ip: - print(" %s[%-25.25s] %sError: No IP found%s" % - (term.subbold, name, term.alert, term.normal)) + print(" %s[%-25.25s] %sNo IP found - Battery-powered or offline%s" % + (term.subbold, name, term.alertdim, term.normal)) elif not dps: print(" %s[%-25.25s] %s%-18s - %sNo Response" % (term.subbold, name, term.dim, ip, term.alert)) diff --git a/tinytuya/wizard.py b/tinytuya/wizard.py index 55902de..d5c3d06 100644 --- a/tinytuya/wizard.py +++ b/tinytuya/wizard.py @@ -192,6 +192,9 @@ def wizard(color=True, retries=None, forcescan=False, nocloud=False, assume_yes= if cloud.error: err = cloud.error['Payload'] if 'Payload' in cloud.error else 'Unknown Error' print('\n\n' + bold + 'Error from Tuya server: ' + dim + err) + if 'permission' in str(err).lower() or '1010' in str(err): + print(bold + 'Hint: ' + dim + 'This may indicate your Tuya IoT subscription has expired.') + print(' Visit https://iot.tuya.com to check and renew your IoT Core service.') print('Check API Key and Secret') return @@ -208,6 +211,9 @@ def wizard(color=True, retries=None, forcescan=False, nocloud=False, assume_yes= if type(tuyadevices) != list: err = tuyadevices['Payload'] if 'Payload' in tuyadevices else 'Unknown Error' print('\n\n' + bold + 'Error from Tuya server: ' + dim + err) + if 'permission' in str(err).lower() or '1010' in str(err): + print(bold + 'Hint: ' + dim + 'This may indicate your Tuya IoT subscription has expired.') + print(' Visit https://iot.tuya.com to check and renew your IoT Core service.') print('Check DeviceID and Region') return