diff --git a/tasmotonov.py b/tasmotonov.py new file mode 100755 index 0000000..a22c2e1 --- /dev/null +++ b/tasmotonov.py @@ -0,0 +1,134 @@ +#!/usr/bin/python3 + +import sys # for exiting with the correct exit code +import argparse # to parse the cli args! +import requests # needed to access the http endpoints +import ipaddress # to validate IP addresses +from fqdn import FQDN # validate FQDNs + +parser = argparse.ArgumentParser( + prog='Tasmotonov - simply toggle multiple tasmota lights', + description='A very simple script which allows you to turn on/off multiple tasmota devices specified.', + epilog='Info: if you choose a file as source, this files needs to contain the addresses of the tasmota devices either comma-separated, semicolon-separated, or newline-separated!\n\n© Benjamin Burkhardt, 2025') + +parser.add_argument('source', help='Select either to read the adresses (of the devices) from a "file" or from "inline"', choices=['file', 'inline']) +parser.add_argument('data', help='Either the path to the file, or a comma- or semicolon-separated list of tasmota adresses.') +parser.add_argument('action', help='Select to turn all tasmota devices "on" or "off" or "toggle" (case insensitive)', choices=['on', 'off', 'toggle']) +parser.add_argument('-v', '--verbose', help='Turn on verbose file output', action='store_true') + + +# some helpers / utils + +def is_ip(string): + try: + i = ipaddress.ip_address(string) + del i + except ValueError: + return False + return True + +def is_fqdn(string): + return FQDN(string).is_valid + + +# logging + +class COLORS: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKCYAN = '\033[96m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + +def log(to_log, verbose = True, end = '\n'): + if (verbose and args.verbose) or not verbose: + print(to_log, end=end) +def log_error(to_log, end='\n'): + log(COLORS.FAIL + '[ERROR] ' + COLORS.ENDC + to_log, verbose=False, end=end) +def log_warning(to_log, end='\n'): + log(COLORS.WARNING + '[WARNING] ' + COLORS.ENDC + to_log, verbose=False, end=end) +def log_success(to_log, end='\n'): + log(COLORS.OKGREEN + '[SUCCESS] ' + COLORS.ENDC + to_log, verbose=False, end=end) + + +# the real program + +if __name__ == '__main__': + args = parser.parse_args() # parse all given arguments + + # convert the given adresses to a list + tasmota_addresses = [] + if args.source == 'file': + try: + log('Now trying to open the given file...', end='') + with open(args.data, 'r') as file: + contents = file.read() + if ';' in contents: + tasmota_addresses = contents.split(';') + elif ',' in contents: + tasmota_addresses = contents.split(',') + elif '\n' in contents: + tasmota_addresses = contents.split('\n') + else: + tasmota_addresses = [contents] + log('Done.') + log(f'Collected addresses: {tasmota_addresses}') + except FileNotFoundError: + log_error(f'Failed reading addresses. File with the path "{args.data} can\'t be opened because it doesn\'t exist. Exiting.') + sys.exit(1) + + elif args.source == 'inline': + log('Now reading from inline... ', end='') + if ';' in args.data: + tasmota_addresses = args.data.split(';') + elif ',' in args.data: + tasmota_addresses = args.data.split(',') + elif '\n' in args.data: + tasmota_addresses = args.data.split('\n') + else: + tasmota_addresses = [args.data] + log('Done.') + log(f'Collected addresses: {tasmota_addresses}') + + else: + log_error(f'You need to specify either "file" or "inline" as the data source for addresses!') + sys.exit(1) + + + # clean them up (e.g. remove newlines if the file has a random newline somewhere + tasmota_addresses_cleaned = [] + for address in tasmota_addresses: + if address != '': + tasmota_addresses_cleaned.append(address.replace('\n', '').replace(';', '').replace(',', '')) + + # now check data for consistency and integrity (is it really an ip address or a domain name?) + tasmota_addresses_validated = [] + log('Now validating given addresses...') + for address in tasmota_addresses_cleaned: + if is_ip(address) or is_fqdn(address): + tasmota_addresses_validated.append(address) + else: + log_warning(f'The address "{address}" is neither a valid IP address nor a FQDN / domain name; SKIPPING THIS ONE.') + log('Validated given adddresses.') + + + # and finally turn on or off or toggle the lights! + for address in tasmota_addresses_validated: + log('Running the action "{args.action}" on the following device: {address}') + try: + answer = requests.get(f'http://{address}/cm?cmnd=Power%20{str(args.action).lower()}') + success = True + except requests.exceptions.ConnectionError: + log_warning(f'Could not connect to "{address}". Skipping...') + success = False + if answer.status_code != 200: + log_warning(f'{address} returned the following non-200 status code: {answer.status_code}. Skipping...') + success = False + if success: + log_success(f"Ran action {str(args.action).upper()} on {COLORS.OKCYAN}{address}{COLORS.ENDC} successfully.") + + sys.exit(0)