10 Commits

6 changed files with 502 additions and 108 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")
## 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:
- `fqdn`: for validating the FQDN
- `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

View File

@@ -1,5 +1,2 @@
192.168.30.69
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

@@ -1,28 +1,32 @@
#!/usr/bin/python3
import sys # for exiting with the correct exit code
"""
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 <https://www.gnu.org/licenses/>.
"""
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}')
version = "v1.1.0"
# some helpers / utils
def is_ip(string):
try:
i = ipaddress.ip_address(string)
@@ -36,104 +40,192 @@ def is_fqdn(string):
# 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}')
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'
}
else:
log_error(f'You need to specify either "file" or "inline" as the data source for addresses!')
sys.exit(1)
def log(self, to_log, verbose = True, end = '\n'):
if self.enable_stdout and ((verbose and self.verbose) or not verbose):
print(to_log, end=end)
if ((verbose and self.verbose) or not verbose):
# remove the shell colors
to_log_color_cleaned = to_log
for color in list(self.COLORS.values()):
to_log_color_cleaned = to_log_color_cleaned.replace(color, '')
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)
# 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)
# 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:
log_warning(f'The address "{address}" is neither a valid IP address nor a FQDN / domain name; SKIPPING THIS ONE.')
log('Validated given adddresses.')
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)
self.logger.log(f'Initialized runner: source={source}, data={data}, action={action}, verbose={verbose}')
self.tasmota_addresses = self.remove_invalid_addresses(self.clean_addresses(self.parse_addresses())) # returns a list
def parse_addresses(self):
# convert the given adresses to a list
tasmota_addresses = []
if self.source == 'file':
try:
self.logger.log('Now trying to open the given file...', end='')
with open(self.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')
elif ' ' in contents:
tasmota_addresses = contents.split(' ')
else:
tasmota_addresses = [contents]
self.logger.log('Done.')
self.logger.log(f'Collected addresses: {tasmota_addresses}')
except FileNotFoundError as e:
self.logger.log_error(f'Failed reading addresses. File with the path "{self.data} can\'t be opened because it doesn\'t exist. Exiting.')
raise e
elif self.source == 'inline':
self.logger.log('Now reading from inline... ', end='')
if ';' in self.data:
tasmota_addresses = self.data.split(';')
elif ',' in self.data:
tasmota_addresses = self.data.split(',')
elif '\n' in self.data:
tasmota_addresses = self.data.split('\n')
elif ' ' in self.data:
tasmota_addresses = self.data.split(' ')
else:
tasmota_addresses = [self.data]
self.logger.log('Done.')
self.logger.log(f'Collected addresses: {tasmota_addresses}')
# 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}')
return tasmota_addresses
def clean_addresses(self, addresses_raw):
# clean them up (e.g. remove newlines if the file has a random newline somewhere
tasmota_addresses_cleaned = []
for address in addresses_raw:
to_add = address.replace('\n', '').replace(';', '').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?)
# remove the ones that are not valid and log a warning
tasmota_addresses_validated = []
self.logger.log('Now validating given addresses...')
for address in addressse_raw:
if is_ip(address) or is_fqdn(address):
tasmota_addresses_validated.append(address)
else:
self.logger.log_warning(f'The address "{address}" is neither a valid IP address nor a FQDN / domain name; SKIPPING THIS ONE.')
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!
for address in self.tasmota_addresses:
self.logger.log('Running the action "{action}" on the following device: {address}')
try:
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.status_code != 200:
self.logger.log_warning(f'{address} returned the following non-200 status code: {answer.status_code}. 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(self):
self.run_custom_action(self.action)
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 = requests.get(f'http://{address}/cm?cmnd=Power%20{str(args.action).lower()}')
answer = None
answer = requests.get(f'http://{address}/cm?cmnd=Power%20{str(action).lower()}')
success = True
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
if answer.status_code != 200:
log_warning(f'{address} returned the following non-200 status code: {answer.status_code}. Skipping...')
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:
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_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}')
sys.exit(0)
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>