#!/usr/bin/python3 """ Tasmotonov - A very simple script which allows you to turn on/off (or toggle) multiple tasmota devices specified. Copyright (C) 2025 Benjamin Burkhardt This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ 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 version = "v0.1.1" parser = argparse.ArgumentParser( prog='Tasmotonov - simply toggle multiple tasmota lights', formatter_class=argparse.RawDescriptionHelpFormatter, 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') parser.add_argument('--version', action='version', version=f'Tasmotonov.py {version}') # 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 log(f'Parsed args: {args}') # 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)