8 Commits

6 changed files with 485 additions and 104 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
config.ini
__pycache__/

View File

@@ -11,13 +11,69 @@ Maybe it's straightforward or obvious, but just for completeness: the name comes
2. The ability to turn on and off tasmota devices ("on" and "off" pronounced directly one after the other sounds (a bit) like "onov") 2. The ability to turn on and off tasmota devices ("on" and "off" pronounced directly one after the other sounds (a bit) like "onov")
## Dependencies ## CLI Usage
```
usage: Tasmotonov - simply toggle multiple tasmota lights [-h] [-v] [--version] {file,inline} data {on,off,toggle}
A very simple script which allows you to turn on/off multiple tasmota devices specified.
positional arguments:
{file,inline} Select either to read the adresses (of the devices) from a "file" or from "inline"
data Either the path to the file, or a comma- or semicolon-separated list of tasmota adresses.
{on,off,toggle} Select to turn all tasmota devices "on" or "off" or "toggle" (case insensitive)
options:
-h, --help show this help message and exit
-v, --verbose Turn on verbose file output
--version show program's version number and exit
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!
© Benjamin Burkhardt, 2025
```
## Installation
The CLI script ([tasmotonov.py](tasmotonov.py)) relies on two libaries apart from python3's standard libraries: The CLI script ([tasmotonov.py](tasmotonov.py)) relies on two libaries apart from python3's standard libraries:
- `fqdn`: for validating the FQDN - `fqdn`: for validating the FQDN
- `requests`: for making the HTTP requests - `requests`: for making the HTTP requests
To install it, just execute the following command:
```bash
pip install fqdn requests
```
---
The GUI application is based on Qt with it's python3 bindings PyQt6:
- `PySide6`: for running all the GUI stuff
To install it, just execute the following command:
```bash
pip install PySide6
```
## Running
CLI: Use `./tasmotonov.py`
GUI: Use `./tasmotonov-gui.py`
## Plans
I plan to add
- a darkmode maybe (but of course the interface doesn't look good either lol ;)
- and bundle everything together to get a desktop application (e.g. runnable under windows without development tools)
## License ## License

2
example.addr Normal file
View File

@@ -0,0 +1,2 @@
192.168.30.71
192.168.30.69

77
tasmotonov-gui.py Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/python3
import sys
import subprocess
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication,QFileDialog
from PySide6.QtCore import QFile, QUrl, QIODevice
filename = ""
action = "toggle"
def tab1_load_file():
global filename
dialog = QFileDialog()
dialog.setFileMode(QFileDialog.AnyFile)
#fileNames = QStringList()
if dialog.exec():
filename = dialog.selectedFiles()[0]
window.tab1_label.setText(f"File loaded: {filename}")
window.tab1_label.show()
window.tab1_filecontent_textBrowser.setSource(QUrl(filename))
def tab1_action():
if filename != "":
p = subprocess.Popen(["python3", "tasmotonov.py", "file", filename, action], stdout=subprocess.PIPE)
out, err = p.communicate()
window.tab3_textBrowser.append("==== RUNNING ====\n" + str(out) + "\n=================\n")
else:
window.tab3_textBrowser.append("Will not run, no file selected!")
def tab2_action():
content = window.tab2_plainTextEdit.toPlainText()
if content != "":
p = subprocess.Popen(["python3", "tasmotonov.py", "inline", content, action], stdout=subprocess.PIPE)
out, err = p.communicate()
window.tab3_textBrowser.append("==== RUNNING ====\n" + str(out) + "\n=================\n")
print(out)
else:
window.tab3_textBrowser.append("Will not run, no input given!")
def select_on():
global action
action = "on"
window.tab3_textBrowser.append("Now turning everything on when running.")
def select_off():
global action
action = "off"
window.tab3_textBrowser.append("Now turning everything off when running.")
def select_toggle():
global action
action = "toggle"
window.tab3_textBrowser.append("Now toggling when running.")
if __name__ == "__main__":
app = QApplication(sys.argv)
ui_file_name = "tasmotonov.ui"
ui_file = QFile(ui_file_name)
if not ui_file.open(QIODevice.ReadOnly):
print(f"Cannot open {ui_file_name}: {ui_file.errorString()}")
sys.exit(-1)
loader = QUiLoader()
window = loader.load(ui_file)
ui_file.close()
if not window:
print(loader.errorString())
sys.exit(-1)
window.tab1_label.hide()
window.tab1_load_file_pushButton.clicked.connect(tab1_load_file)
window.tab1_action_pushButton.clicked.connect(tab1_action)
window.tab2_action_pushButton.clicked.connect(tab2_action)
window.radioButton_on.clicked.connect(select_on)
window.radioButton_off.clicked.connect(select_off)
window.radioButton_toggle.clicked.connect(select_toggle)
window.show()
sys.exit(app.exec())

View File

@@ -18,29 +18,15 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import sys # for exiting with the correct exit code
import argparse # to parse the cli args! import argparse # to parse the cli args!
import requests # needed to access the http endpoints import requests # needed to access the http endpoints
import ipaddress # to validate IP addresses import ipaddress # to validate IP addresses
from fqdn import FQDN # validate FQDNs from fqdn import FQDN # validate FQDNs
version = "v0.1.1" version = "v1.1.0"
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 # some helpers / utils
def is_ip(string): def is_ip(string):
try: try:
i = ipaddress.ip_address(string) i = ipaddress.ip_address(string)
@@ -54,41 +40,74 @@ def is_fqdn(string):
# logging # logging
class Logger():
def __init__(self, verbose=True, enable_stdout=True):
self.verbose = verbose
self.enable_stdout = enable_stdout
self.log_string = "" # string variable where the logged lines are stored
self.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'
}
class COLORS: def log(self, to_log, verbose = True, end = '\n'):
HEADER = '\033[95m' if self.enable_stdout and ((verbose and self.verbose) or not verbose):
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) print(to_log, end=end)
def log_error(to_log, end='\n'): if ((verbose and self.verbose) or not verbose):
log(COLORS.FAIL + '[ERROR] ' + COLORS.ENDC + to_log, verbose=False, end=end) # remove the shell colors
def log_warning(to_log, end='\n'): to_log_color_cleaned = to_log
log(COLORS.WARNING + '[WARNING] ' + COLORS.ENDC + to_log, verbose=False, end=end) for color in list(self.COLORS.values()):
def log_success(to_log, end='\n'): to_log_color_cleaned = to_log_color_cleaned.replace(color, '')
log(COLORS.OKGREEN + '[SUCCESS] ' + COLORS.ENDC + to_log, verbose=False, end=end) self.log_string += to_log_color_cleaned + end
def log_error(self, to_log, end='\n'):
self.log(self.COLORS['FAIL'] + '[ERROR] ' + self.COLORS['ENDC'] + to_log, verbose=False, end=end)
def log_warning(self, to_log, end='\n'):
self.log(self.COLORS['WARNING'] + '[WARNING] ' + self.COLORS['ENDC'] + to_log, verbose=False, end=end)
def log_success(self, to_log, end='\n'):
self.log(self.COLORS['OKGREEN'] + '[SUCCESS] ' + self.COLORS['ENDC'] + to_log, verbose=False, end=end)
# the real program # the tasmotonov runner class
class TasmotonovRunner:
def __init__(self, source, data, action, verbose=True, enable_stdout=False):
if str(source).lower() in ['file', 'inline']:
self.source = source
else:
error_string = 'The source param must be either "file" or "inline" (case-insensitive)'
logger.log_error(error_string)
raise ValueError(error_string)
self.data = data
if str(action).upper() in ['ON', 'OFF', 'TOGGLE']:
self.action = action.upper()
else:
error_string = 'The action param must be either "on", "off" or "toggle" (case-insensitive)'
logger.log_error(error_string)
raise ValueError(error_string)
if type(verbose) == bool and type(enable_stdout) == bool:
self.logger = Logger(verbose, enable_stdout)
else:
error_string = 'Both verbose and enable_stdout params must be boolean'
logger.log_error(error_string)
raise ValueError(error_string)
if __name__ == '__main__': self.logger.log(f'Initialized runner: source={source}, data={data}, action={action}, verbose={verbose}')
args = parser.parse_args() # parse all given arguments self.tasmota_addresses = self.remove_invalid_addresses(self.clean_addresses(self.parse_addresses())) # returns a list
log(f'Parsed args: {args}')
def parse_addresses(self):
# convert the given adresses to a list # convert the given adresses to a list
tasmota_addresses = [] tasmota_addresses = []
if args.source == 'file': if self.source == 'file':
try: try:
log('Now trying to open the given file...', end='') self.logger.log('Now trying to open the given file...', end='')
with open(args.data, 'r') as file: with open(self.data, 'r') as file:
contents = file.read() contents = file.read()
if ';' in contents: if ';' in contents:
tasmota_addresses = contents.split(';') tasmota_addresses = contents.split(';')
@@ -96,62 +115,117 @@ if __name__ == '__main__':
tasmota_addresses = contents.split(',') tasmota_addresses = contents.split(',')
elif '\n' in contents: elif '\n' in contents:
tasmota_addresses = contents.split('\n') tasmota_addresses = contents.split('\n')
elif ' ' in contents:
tasmota_addresses = contents.split(' ')
else: else:
tasmota_addresses = [contents] tasmota_addresses = [contents]
log('Done.') self.logger.log('Done.')
log(f'Collected addresses: {tasmota_addresses}') self.logger.log(f'Collected addresses: {tasmota_addresses}')
except FileNotFoundError: except FileNotFoundError as e:
log_error(f'Failed reading addresses. File with the path "{args.data} can\'t be opened because it doesn\'t exist. Exiting.') self.logger.log_error(f'Failed reading addresses. File with the path "{self.data} can\'t be opened because it doesn\'t exist. Exiting.')
sys.exit(1) raise e
elif args.source == 'inline': elif self.source == 'inline':
log('Now reading from inline... ', end='') self.logger.log('Now reading from inline... ', end='')
if ';' in args.data: if ';' in self.data:
tasmota_addresses = args.data.split(';') tasmota_addresses = self.data.split(';')
elif ',' in args.data: elif ',' in self.data:
tasmota_addresses = args.data.split(',') tasmota_addresses = self.data.split(',')
elif '\n' in args.data: elif '\n' in self.data:
tasmota_addresses = args.data.split('\n') tasmota_addresses = self.data.split('\n')
elif ' ' in self.data:
tasmota_addresses = self.data.split(' ')
else: else:
tasmota_addresses = [args.data] tasmota_addresses = [self.data]
log('Done.') self.logger.log('Done.')
log(f'Collected addresses: {tasmota_addresses}') self.logger.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)
return tasmota_addresses
def clean_addresses(self, addresses_raw):
# clean them up (e.g. remove newlines if the file has a random newline somewhere # clean them up (e.g. remove newlines if the file has a random newline somewhere
tasmota_addresses_cleaned = [] tasmota_addresses_cleaned = []
for address in tasmota_addresses: for address in addresses_raw:
if address != '': to_add = address.replace('\n', '').replace(';', '').replace(',', '').replace(' ', '')
tasmota_addresses_cleaned.append(address.replace('\n', '').replace(';', '').replace(',', '')) if to_add != '':
tasmota_addresses_cleaned.append(to_add)
return tasmota_addresses_cleaned
def remove_invalid_addresses(self, addressse_raw):
# now check data for consistency and integrity (is it really an ip address or a domain name?) # now check data for consistency and integrity (is it really an ip address or a domain name?)
# remove the ones that are not valid and log a warning
tasmota_addresses_validated = [] tasmota_addresses_validated = []
log('Now validating given addresses...') self.logger.log('Now validating given addresses...')
for address in tasmota_addresses_cleaned: for address in addressse_raw:
if is_ip(address) or is_fqdn(address): if is_ip(address) or is_fqdn(address):
tasmota_addresses_validated.append(address) tasmota_addresses_validated.append(address)
else: else:
log_warning(f'The address "{address}" is neither a valid IP address nor a FQDN / domain name; SKIPPING THIS ONE.') self.logger.log_warning(f'The address "{address}" is neither a valid IP address nor a FQDN / domain name; SKIPPING THIS ONE.')
log('Validated given adddresses.') self.logger.log('Validated given adddresses.')
return tasmota_addresses_validated
# some getters
def get_addresses(self):
return self.tasmota_addresses
def get_address(self, index):
return self.tasmota_addresses[index]
def run_custom_action(self, action):
# and finally turn on or off or toggle the lights! # and finally turn on or off or toggle the lights!
for address in tasmota_addresses_validated: for address in self.tasmota_addresses:
log('Running the action "{args.action}" on the following device: {address}') self.logger.log('Running the action "{action}" on the following device: {address}')
try: try:
answer = requests.get(f'http://{address}/cm?cmnd=Power%20{str(args.action).lower()}') answer = requests.get(f'http://{address}/cm?cmnd=Power%20{str(action).lower()}')
success = True success = True
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
log_warning(f'Could not connect to "{address}". Skipping...') self.logger.log_warning(f'Could not connect to "{address}". Skipping...')
success = False success = False
if answer.status_code != 200: if answer.status_code != 200:
log_warning(f'{address} returned the following non-200 status code: {answer.status_code}. Skipping...') self.logger.log_warning(f'{address} returned the following non-200 status code: {answer.status_code}. Skipping...')
success = False success = False
if success: if success:
log_success(f"Ran action {str(args.action).upper()} on {COLORS.OKCYAN}{address}{COLORS.ENDC} successfully.") self.logger.log_success(f"Ran action {str(action).upper()} on {self.logger.COLORS['OKCYAN']}{address}{self.logger.COLORS['ENDC']} successfully.")
def run(self):
self.run_custom_action(self.action)
sys.exit(0) def run_single_custom_action(self, index, action):
address = self.tasmota_addresses[index]
self.logger.log('Running the action "{action}" on the following device: {address}')
try:
answer = None
answer = requests.get(f'http://{address}/cm?cmnd=Power%20{str(action).lower()}')
success = True
except requests.exceptions.ConnectionError:
self.logger.log_warning(f'Could not connect to "{address}". Skipping...')
success = False
if answer:
if answer.status_code != 200:
self.logger.log_warning(f'{address} returned the following non-200 status code: {answer.status_code}. Skipping...')
success = False
else:
self.logger.log_warning(f'{address} does not work. Skipping...')
success = False
if success:
self.logger.log_success(f"Ran action {str(action).upper()} on {self.logger.COLORS['OKCYAN']}{address}{self.logger.COLORS['ENDC']} successfully. ")
def run_single(self, index):
self.run_single_custom_action(index, self.action)
# when run as a script in CLI
if __name__ == '__main__':
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, space-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-, space- 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}')
args = parser.parse_args() # parse all given arguments
runner = TasmotonovRunner(source=args.source, data=args.data, action=args.action, verbose=args.verbose, enable_stdout=True)
runner.run()

170
tasmotonov.ui Normal file
View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>mainWindow</class>
<widget class="QMainWindow" name="mainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>630</width>
<height>404</height>
</rect>
</property>
<property name="windowTitle">
<string>Tasmotonov</string>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<widget class="QTabWidget" name="tabWidget">
<property name="tabPosition">
<enum>QTabWidget::TabPosition::East</enum>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<property name="currentIndex">
<number>1</number>
</property>
<property name="usesScrollButtons">
<bool>true</bool>
</property>
<property name="documentMode">
<bool>true</bool>
</property>
<widget class="QWidget" name="tab1_from_file">
<attribute name="title">
<string>From File</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_2">
<item row="3" column="0">
<widget class="QTextBrowser" name="tab1_filecontent_textBrowser">
<property name="frameShadow">
<enum>QFrame::Shadow::Sunken</enum>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="tab1_label">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>File loaded: </string>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QPushButton" name="tab1_action_pushButton">
<property name="text">
<string>Run!</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="tab1_load_file_pushButton">
<property name="text">
<string>Load file</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab2_from_inline">
<attribute name="title">
<string>From Inline</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPlainTextEdit" name="tab2_plainTextEdit">
<property name="lineWrapMode">
<enum>QPlainTextEdit::LineWrapMode::WidgetWidth</enum>
</property>
<property name="plainText">
<string/>
</property>
<property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextEditable|Qt::TextInteractionFlag::TextEditorInteraction|Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse</set>
</property>
<property name="placeholderText">
<string>Enter your addresses here (IP-Adresses, or Domain names / FQDNs). The list can be either comma-, semicolon-, or whitespace-separated, but should not be mixed!</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="tab2_action_pushButton">
<property name="text">
<string>Run!</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab3_output">
<attribute name="title">
<string>Console</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="tab3_label_output">
<property name="text">
<string>Tasmotonov.py output</string>
</property>
</widget>
</item>
<item>
<widget class="QTextBrowser" name="tab3_textBrowser"/>
</item>
</layout>
</widget>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QRadioButton" name="radioButton_on">
<property name="text">
<string>All ON!</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="radioButton_off">
<property name="text">
<string>All OFF!</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="radioButton_toggle">
<property name="text">
<string>TOGGLE all!</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections/>
</ui>