Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
252 changes: 38 additions & 214 deletions tinytuya/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"""

# Modules
import json
import sys
import argparse
try:
Expand All @@ -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,)
Expand All @@ -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' )
Expand All @@ -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:
Expand All @@ -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 '<unknown>',
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)
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading