Compare commits

...

28 Commits
v0.1.0 ... main

Author SHA1 Message Date
0e4f5dfa9e Version 1.2.1 2025-05-17 23:22:11 +02:00
2e89501613 Removed misspelling of the word 'address' 2025-05-17 23:21:31 +02:00
3a4e99d554 Fixed wrong image being displayed in README.md 2025-05-17 23:19:54 +02:00
1e055b7910 Changed language in the updated screenshot 2025-05-17 23:19:01 +02:00
a01c8974ed Updated tab2 screenshot to showcase the comment functionality 2025-05-17 23:17:04 +02:00
9e0703051f New version number 2025-05-17 23:14:04 +02:00
fff3699efe Added line number saving 2025-05-17 23:12:11 +02:00
3999de77d7 Changed config save mechanism 2025-05-17 23:01:44 +02:00
023b29fcae Now using threading for running the bulk action 2025-05-17 22:49:55 +02:00
44b48485be Added option to add comments to the addresses lists (inline and file mode) 2025-05-17 22:35:34 +02:00
e06cc1d4fc Fixed bug when saved selected file (in the config.ini) has been deleted before program start 2025-05-17 15:08:48 +02:00
aae5152288 Fixed typo in the file string: - instead of _ 2025-05-17 15:03:25 +02:00
cd3688b0b3 Rename tasmotonov.ui to tasmotonov-gui.ui 2025-05-17 15:01:19 +02:00
774d2a1e83 Added license info to the gui script 2025-05-17 15:00:15 +02:00
58366036a2 Added screenshots to the README.md file 2025-05-17 14:55:02 +02:00
47c498dddb Made tab titles more accurate 2025-05-17 14:54:48 +02:00
2f4c3103b8 Added options for single actions, saving config now, some backend improvements 2025-05-17 14:49:04 +02:00
462c22ed9a Added example address file 2025-05-17 14:47:38 +02:00
5c9cdfd78e Changed tastmotonov.py's structure to make it object-oriented and importable from python3 2025-05-17 14:47:12 +02:00
f5f132914b Version 1.0.0 release 2025-05-17 02:25:05 +02:00
393b5e34b8 [tasmotonov.py] Small improvement in handling input strings 2025-05-17 02:24:00 +02:00
b088ad2cdb Added GUI files initially 2025-05-17 02:23:05 +02:00
a408bb05a4 Fixed small typo 2025-05-17 00:19:12 +02:00
5af51ab79f Added usage section to README.md 2025-05-17 00:19:01 +02:00
c68e1e23fb Added more instructions 2025-05-17 00:17:46 +02:00
231d95a0f7 Added license notice to the top of the tasmotonov.py file 2025-05-17 00:15:24 +02:00
b9eaf0928a Removed some testing files 2025-05-17 00:13:20 +02:00
9ed7c53b33 [Tasmotonov.py] Improved cli interface @v0.1.1 2025-05-17 00:08:47 +02:00
8 changed files with 714 additions and 98 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
config.ini
tasmotonov-gui-config.json
__pycache__/

View File

@ -10,14 +10,74 @@ Maybe it's straightforward or obvious, but just for completeness: the name comes
1. The direct bond to tasmota (written for its "API" if one can call the HTTP endpoints an API)
2. The ability to turn on and off tasmota devices ("on" and "off" pronounced directly one after the other sounds (a bit) like "onov")
## Screenshots
## Dependencies
![Tab 1 of the GUI](assets/gui_tab1.png)
![Tab 2 of the GUI](assets/gui_tab2.png)
## 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

BIN
assets/gui_tab1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
assets/gui_tab2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

2
example.addr Normal file
View File

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

206
tasmotonov-gui.py Executable file
View File

@ -0,0 +1,206 @@
#!/usr/bin/python3
"""
Tasmotonov GUI - A simple Qt wrapper around the tasmotonov.py script
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 sys
import tasmotonov
import json
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication,QFileDialog
from PySide6.QtCore import QFile, QUrl, QIODevice
# set some standard values
filename = ""
action = "toggle"
def load_config(file="tasmotonov-gui-config.json"): # load config from configparser.ConfigParser object
try:
with open(file, "r") as f:
to_load = json.loads(f.read())
global filename, action
if "file" in to_load:
filename = to_load['file']
if filename != "":
try:
tab1_load_file()
except FileNotFoundError:
tab1_clear()
if "inline" in to_load:
window.tab2_plainTextEdit.setPlainText(to_load['inline'])
if "action" in to_load:
a_new = to_load['action']
if a_new != "":
action = a_new
if "tab_index" in to_load:
tab_index = to_load["tab_index"]
if type(tab_index) == int and (0 <= tab_index < window.tabWidget.count()):
window.tabWidget.setCurrentIndex(to_load["tab_index"])
except FileNotFoundError:
pass # no config there yet
def save_config(file="tasmotonov-gui-config.json"):
window.tab3_textBrowser.append("Saving configuration!")
global filename, action
to_save = {}
to_save['file'] = str(filename)
to_save['inline'] = str(window.tab2_plainTextEdit.toPlainText())
to_save['action'] = str(action)
to_save['tab_index'] = window.tabWidget.currentIndex()
with open(file, 'w') as f:
f.write(json.dumps(to_save))
# tab1 slots
def tab1_load_file_pressed():
global filename
dialog = QFileDialog()
dialog.setFileMode(QFileDialog.AnyFile)
if dialog.exec():
filename = dialog.selectedFiles()[0]
tab1_load_file()
def tab1_load_file():
global filename
window.tab1_label.setText(f"File loaded: {filename}")
window.tab3_textBrowser.append(f"File loaded: {filename}")
window.tab1_label.show()
window.tab1_filecontent_textBrowser.setSource(QUrl(filename))
window.tab1_label_help.show()
# clear listWidget and add the items
window.tab1_listWidget.clear()
addresses = tasmotonov.TasmotonovRunner("file", filename, action, False, False).get_addresses()
items_to_add = []
for address, comment in addresses.items():
if comment == "":
items_to_add.append(address)
else:
items_to_add.append(f"{comment} [{address}]")
window.tab1_listWidget.addItems(items_to_add)
def tab1_clear():
global filename
filename = ""
window.tab1_label.setText(f"File loaded: ")
window.tab3_textBrowser.append(f"File unloaded")
window.tab1_label.hide()
window.tab1_label_help.hide()
window.tab1_filecontent_textBrowser.clear()
window.tab1_listWidget.clear()
def tab1_action():
if filename != "":
tasmotonov_runner = tasmotonov.TasmotonovRunner("file", filename, action, False, False)
tasmotonov_runner.run()
window.tab3_textBrowser.append("\n==== RUNNING ====\n\n" + str(tasmotonov_runner.logger.log_string) + "\n=================\n")
tasmotonov_runner.logger.log_string = ""
else:
window.tab3_textBrowser.append("Will not run, no file selected!")
def tab1_single_action():
if filename != "":
tasmotonov_runner = tasmotonov.TasmotonovRunner("file", filename, action, False, False)
tasmotonov_runner.run_single(window.tab1_listWidget.currentRow())
window.tab3_textBrowser.append(str(tasmotonov_runner.logger.log_string))
# tab2 slots
def tab2_plainTextEdit_change():
# clear listWidget
window.tab2_listWidget.clear()
content = window.tab2_plainTextEdit.toPlainText()
if content != "":
tasmotonov_runner = tasmotonov.TasmotonovRunner("inline", content, action, False, False)
addresses = tasmotonov_runner.get_addresses()
items_to_add = []
for address, comment in addresses.items():
if comment == "":
items_to_add.append(address)
else:
items_to_add.append(f"{comment} [{address}]")
window.tab2_listWidget.addItems(items_to_add)
window.tab3_textBrowser.append(str(tasmotonov_runner.logger.log_string))
def tab2_action():
content = window.tab2_plainTextEdit.toPlainText()
if content != "":
tasmotonov_runner = tasmotonov.TasmotonovRunner("inline", content, action, False, False)
tasmotonov_runner.run()
window.tab3_textBrowser.append("\n==== RUNNING ====\n\n" + str(tasmotonov_runner.logger.log_string) + "\n=================\n")
else:
window.tab3_textBrowser.append("Will not run, no input given!")
def tab2_single_action():
content = window.tab2_plainTextEdit.toPlainText()
tasmotonov_runner = tasmotonov.TasmotonovRunner("inline", content, action, False, False)
if len(tasmotonov_runner.get_addresses()) != 0:
tasmotonov_runner.run_single(window.tab2_listWidget.currentRow())
window.tab3_textBrowser.append(str(tasmotonov_runner.logger.log_string))
# tab3 slots
def tab3_clear():
window.tab3_textBrowser.clear()
# other slots
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-gui.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_label_help.hide()
window.tab1_load_file_pushButton.clicked.connect(tab1_load_file_pressed)
window.tab1_clear_pushButton.clicked.connect(tab1_clear)
window.tab1_action_pushButton.clicked.connect(tab1_action)
window.tab1_single_action_pushButton.clicked.connect(tab1_single_action)
window.tab2_plainTextEdit.textChanged.connect(tab2_plainTextEdit_change)
window.tab2_action_pushButton.clicked.connect(tab2_action)
window.tab2_single_action_pushButton.clicked.connect(tab2_single_action)
window.radioButton_on.clicked.connect(select_on)
window.radioButton_off.clicked.connect(select_off)
window.radioButton_toggle.clicked.connect(select_toggle)
window.tab3_clear_button.clicked.connect(tab3_clear)
load_config()
window.show()
# execute app loop and define exit strategy
code = app.exec()
save_config()
sys.exit(code)

243
tasmotonov-gui.ui Normal file
View File

@ -0,0 +1,243 @@
<?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>1166</width>
<height>546</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="QVBoxLayout" name="verticalLayout">
<item>
<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>0</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="8" column="0">
<widget class="QPushButton" name="tab1_action_pushButton">
<property name="minimumSize">
<size>
<width>414</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Run bulk action (specified below)</string>
</property>
</widget>
</item>
<item row="8" column="1">
<widget class="QPushButton" name="tab1_single_action_pushButton">
<property name="text">
<string>Run single action (specified below)</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="tab1_label_help">
<property name="text">
<string>Select an item and run an action:</string>
</property>
</widget>
</item>
<item row="4" 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="6" column="0">
<widget class="QTextBrowser" name="tab1_filecontent_textBrowser">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>414</width>
<height>0</height>
</size>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Sunken</enum>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QListWidget" name="tab1_listWidget"/>
</item>
<item row="1" column="0" colspan="2">
<widget class="QPushButton" name="tab1_load_file_pushButton">
<property name="text">
<string>Load file</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QPushButton" name="tab1_clear_pushButton">
<property name="text">
<string>Clear</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab2_from_inline">
<attribute name="title">
<string>From Inline</string>
</attribute>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<widget class="QListWidget" name="tab2_listWidget"/>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="tab2_single_action_pushButton">
<property name="text">
<string>Run single action (specified below)</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="tab2_action_pushButton">
<property name="text">
<string>Run bulk action (specified below)!</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="tab2_label_help_single">
<property name="text">
<string>Single actions: select the address and click the button below</string>
</property>
</widget>
</item>
<item row="0" column="0" rowspan="2">
<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>
</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">
<property name="acceptDrops">
<bool>false</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="tab3_clear_button">
<property name="text">
<string>Clear</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<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>

View File

@ -1,24 +1,33 @@
#!/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 threading # for running the requests simultaneously
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')
version = "v1.2.1"
# some helpers / utils
def is_ip(string):
try:
i = ipaddress.ip_address(string)
@ -32,103 +41,196 @@ 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)
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'
}
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)
# 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)
self.logger.log(f'Initialized runner: source={source}, data={data}, action={action}, verbose={verbose}')
self.set_valid_addresses_and_comments(self.parse_addresses())
self.logger.log(f'Validated addresses: distilled the following data. Devices added: {self.tasmota_addresses}')
def parse_addresses(self):
# convert the given addresses to a list
tasmota_addresses_raw = []
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_raw = contents.split(',')
elif ';' in contents:
tasmota_addresses_raw = contents.split(';')
elif '\n' in contents:
tasmota_addresses_raw = contents.split('\n')
else:
tasmota_addresses_raw = [contents]
self.logger.log('Done.')
self.logger.log(f'Collected addresses: {tasmota_addresses_raw}')
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
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')
elif self.source == 'inline':
self.logger.log('Now reading from inline... ', end='')
if ',' in self.data:
tasmota_addresses_raw = self.data.split(',')
elif ';' in self.data:
tasmota_addresses_raw = self.data.split(';')
elif '\n' in self.data:
tasmota_addresses_raw = self.data.split('\n')
else:
tasmota_addresses_raw = [self.data]
self.logger.log('Done.')
self.logger.log(f'Collected addresses: {tasmota_addresses_raw}')
return tasmota_addresses_raw
def set_valid_addresses_and_comments(self, tasmota_addresses_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 tasmota_addresses_raw:
address_split = address.split("#") # split to separate existing comments
address_cleaned = address_split[0].replace('\n', '').replace(';', '').replace(',', '').replace(' ', '')
if len(address_split) > 1:
comment = address.split("#")[-1].lstrip()
else:
comment = ""
if address_cleaned != "":
if is_ip(address_cleaned) or is_fqdn(address_cleaned):
tasmota_addresses_validated[address_cleaned] = comment
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}')
self.logger.log_warning(f'The address "{address_cleaned}" (comment: "{comment}") is neither a valid IP address nor a FQDN / domain name; SKIPPING THIS ONE.')
self.tasmota_addresses = tasmota_addresses_validated
else:
log_error(f'You need to specify either "file" or "inline" as the data source for addresses!')
sys.exit(1)
# some getters
def get_addresses(self):
return self.tasmota_addresses
def get_address(self, index):
return {list(self.tasmota_addresses.keys())[index]: list(self.tasmota_addresses.values())[index]}
def run_custom_action(self, action):
# and finally turn on or off or toggle the lights!
def thread_runner(address):
self.logger.log(f'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 not str(answer.status_code).startswith("20"):
self.logger.log_warning(f'{address} returned the following non-20x 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.")
threads = []
for address in list(self.tasmota_addresses.keys()):
t = threading.Thread(target=thread_runner, args=(address,))
threads.append(t)
t.start()
# wait for all threads to finish execution
for t in threads:
t.join()
def run(self):
self.run_custom_action(self.action)
# 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}')
def run_single_custom_action(self, index, action):
address = list(self.tasmota_addresses.keys())[index]
self.logger.log(f'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, or newline-separated (each entry can have a comment starting with a # (hashtag)!\n\n© Benjamin Burkhardt, 2025')
parser.add_argument('source', help='Select either to read the addresses (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-, semicolon- or newline-separated list of tasmota addresses.')
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()