Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
0e4f5dfa9e | |||
2e89501613 | |||
3a4e99d554 | |||
1e055b7910 | |||
a01c8974ed | |||
9e0703051f | |||
fff3699efe | |||
3999de77d7 | |||
023b29fcae | |||
44b48485be | |||
e06cc1d4fc | |||
aae5152288 | |||
cd3688b0b3 | |||
774d2a1e83 | |||
58366036a2 | |||
47c498dddb | |||
2f4c3103b8 | |||
462c22ed9a | |||
5c9cdfd78e | |||
f5f132914b | |||
393b5e34b8 | |||
b088ad2cdb | |||
a408bb05a4 | |||
5af51ab79f | |||
c68e1e23fb | |||
231d95a0f7 | |||
b9eaf0928a | |||
9ed7c53b33 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
config.ini
|
||||
tasmotonov-gui-config.json
|
||||
__pycache__/
|
62
README.md
62
README.md
@ -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
|
||||

|
||||

|
||||
|
||||
## 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
BIN
assets/gui_tab1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
BIN
assets/gui_tab2.png
Normal file
BIN
assets/gui_tab2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 285 KiB |
2
example.addr
Normal file
2
example.addr
Normal file
@ -0,0 +1,2 @@
|
||||
192.168.30.71
|
||||
192.168.30.69
|
206
tasmotonov-gui.py
Executable file
206
tasmotonov-gui.py
Executable 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
243
tasmotonov-gui.ui
Normal 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>
|
296
tasmotonov.py
296
tasmotonov.py
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user